diff --git a/changelog.txt b/changelog.txt index ebfa6de..de5c829 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +- Basemap-Legende für Drucklayouts (#88): Beim Erstellen einer §38-/§41-Vorlage wird ausgewertet, welche Objekte des basemap.de-Vektorkachel-Layers im Verfahrensgebiet (wahlweise im Druckbereich) tatsächlich dargestellt werden; ein Auswahldialog lässt Kategorien abwählen, die gewählten erscheinen mit der Signatur des Projektlayers in einem eigenen Legendenelement „Kartengrundlage (basemap.de)“ über der Quellenangabe. Zuordnung von 434 Basemap-Styles zu 190 Legendeneinträgen (Beschriftungen, Nummernschilder und Hintergrund ausgenommen) in `data/basemap_legende.json` (erzeugt mit `modules/basemap_katalog.py`, fachlich nachbearbeitet) +- Neuer einklappbarer Bereich „Basemap“ im Druck-Tab: Auswertebereich, Basemap-Legende beim Erstellen an/aus, „Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des Basemap-Layers (mehrere Varianten, laden, speichern, als Standard markieren, löschen) – z. B. einmal Hausnummern ausschalten und in jedem Projekt wieder laden (#88) +- Neuer allgemeiner Dialog `ask_mehrfachauswahl` (Liste mit Häkchen, Gruppen, Suche, Symbolvorschau) in `functions/dialog_wrapper.py` (#88) - Bugfix: `Navigation.remove_all()` ruft jetzt `deleteLater()` auf Menü und Toolbar auf, bevor die Referenzen verworfen werden – vorher blieb die Toolbar beim Unload als Kind von main_window bestehen und der Plugin Reloader meldete beim Neuladen "removing duplicated widget(s) not cleaned up by the plugin during unload: LnoSachsenToolbar" (#72) - Restliche „ungeklärt"-Funktionen aus dem Prüfstand entschieden: Tests für Dateipruefer (`_ist_leer`, Priorität in `_handle_leere_eingabe`), Datenschreiber (`_store_style_in_gpkg` – Tabelle anlegen, Default-Stil zurücksetzen, Geometriespalte übernehmen) und Pruefmanager (`_handle_verfahrensgebiet_layer_existiert` „entfernen"/„ersetzen"); Linkpruefer (`_ist_url`, `_pruefe_url`, `_pruefe_dateipfad`), `Pruefmanager._handle_datei_existiert` und `Pruefmanager._ask_use_or_replace_pufferlayer` (toter Code, Bug #60) bekamen den Vermerk „von Test abgesehen" – sn_basis steht jetzt bei 0 ungeklärten Funktionen (#70) - Kategorie „vielleicht testen" abgeschlossen: 30 weitere Funktionen bekamen Tests (os_wrapper, qgiscore_wrapper, qgisui_wrapper, DataGrabber, pruef_ergebnis, Navigation, SettingsTab, PrintTab – Zustandslogik, Signal-Handling, Atlas-/Theme-Verwaltung), `PrintTab._build_ui` bekam den Vermerk „Test ausgesetzt, zunächst Refactor" (#67) diff --git a/data/basemap_legende.json b/data/basemap_legende.json new file mode 100644 index 0000000..b26ffd1 --- /dev/null +++ b/data/basemap_legende.json @@ -0,0 +1,2410 @@ +{ + "beschreibung": "Legendenkatalog für basemap.de Web Vektor. Ordnet jede Style-ID genau einem Legendeneintrag zu. Erzeugt mit modules/basemap_katalog.py, danach fachlich nachbearbeitet.", + "quelle": { + "url": "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/styles/bm_web_col.json", + "name": "bm_web_col", + "version": 8 + }, + "gruppen": [ + "Verkehr", + "Gewässer", + "Vegetation", + "Siedlung und Nutzung", + "Gebäude", + "Bauwerke", + "Relief", + "Grenzen und Schutzgebiete", + "Historisches", + "Versorgung", + "Sonstiges" + ], + "nie_in_legende": [ + "Hintergrund", + "Mittellinie_ausser_Autobahn", + "Bruecke_Mittellinie_ausser_Autobahn", + "SiedlungP_Stadt_groesser_25000", + "SiedlungP_Stadt_groesser_100000", + "SiedlungP_Stadt_groesser_500000", + "SiedlungP_Stadt_groesser_1_Mio", + "SiedlungP_Landeshauptstadt" + ], + "nie_in_legende_praefixe": [ + "Name_", + "Nummer_" + ], + "eintraege": [ + { + "schluessel": "verkehr_klasse_fussgaengerzone", + "bezeichnung": "Fußgängerzone", + "gruppe": "Verkehr", + "treffer_stile": [ + "Verkehrsflaeche_Fussgaengerzone", + "Decker_Fussgaengerzone_Gemeindestr_Sonstige_Str", + "Bruecke_Fussgaengerzone_Gemeindestr_Sonstige_Str_Kontur", + "Bruecke_Fussgaengerzone_Gemeindestr_Sonstige_Str_Decker" + ], + "symbol_stile": [ + "Verkehrsflaeche_Fussgaengerzone" + ] + }, + { + "schluessel": "verkehr_verkehrsflaeche_platz", + "bezeichnung": "Platz", + "gruppe": "Verkehr", + "treffer_stile": [ + "Verkehrsflaeche_Platz" + ], + "symbol_stile": [ + "Verkehrsflaeche_Platz" + ] + }, + { + "schluessel": "verkehr_verkehrsflaeche_flugverkehr", + "bezeichnung": "Flugverkehrsfläche", + "gruppe": "Verkehr", + "treffer_stile": [ + "Verkehrsflaeche_Flugverkehr" + ], + "symbol_stile": [ + "Verkehrsflaeche_Flugverkehr" + ] + }, + { + "schluessel": "verkehr_verkehrsflaeche_flugverkehrsanlage", + "bezeichnung": "Flugverkehrsanlage", + "gruppe": "Verkehr", + "treffer_stile": [ + "Verkehrsflaeche_Flugverkehrsanlage_Kontur", + "Verkehrsflaeche_Flugverkehrsanlage" + ], + "symbol_stile": [ + "Verkehrsflaeche_Flugverkehrsanlage_Kontur", + "Verkehrsflaeche_Flugverkehrsanlage" + ] + }, + { + "schluessel": "verkehr_klasse_startbahn_landebahn_zurollbahn_taxiway", + "bezeichnung": "Start- und Landebahn, Rollbahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "VerkehrsL_Start_Landebahn_Kontur", + "VerkehrsL_Start_Landebahn_Decker" + ], + "symbol_stile": [ + "VerkehrsL_Start_Landebahn_Kontur", + "VerkehrsL_Start_Landebahn_Decker" + ] + }, + { + "schluessel": "verkehr_klasse_bahnverkehr_schiffsverkehr", + "bezeichnung": "Bahn- und Schiffsverkehrsfläche", + "gruppe": "Verkehr", + "treffer_stile": [ + "Verkehrsflaeche_Bahnverkehr_Schiffsverkehr" + ], + "symbol_stile": [ + "Verkehrsflaeche_Bahnverkehr_Schiffsverkehr" + ] + }, + { + "schluessel": "verkehr_verkehrsl_faehrverbindung", + "bezeichnung": "Fährverbindung", + "gruppe": "Verkehr", + "treffer_stile": [ + "VerkehrsL_Faehrverbindung" + ], + "symbol_stile": [ + "VerkehrsL_Faehrverbindung" + ] + }, + { + "schluessel": "verkehr_klasse_wirtschaftsweg", + "bezeichnung": "Wirtschaftsweg", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Wirtschaftsweg", + "Kontur_Wirtschaftsweg", + "Bruecke_Wirtschaftsweg_Kontur", + "Bruecke_Wirtschaftsweg_Decker", + "Bruecke_Wirtschaftsweg", + "Tunnel_Wirtschaftsweg" + ], + "symbol_stile": [ + "Kontur_Wirtschaftsweg" + ] + }, + { + "schluessel": "verkehr_klasse_hauptwirtschaftsweg", + "bezeichnung": "Hauptwirtschaftsweg", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Hauptwirtschaftsweg", + "Kontur_Hauptwirtschaftsweg_einblenden", + "Kontur_Hauptwirtschaftsweg", + "Decker_Hauptwirtschaftsweg", + "Bruecke_Hauptwirtschaftsweg_Kontur", + "Bruecke_Hauptwirtschaftsweg_Decker", + "Tunnel_Hauptwirtschaftsweg" + ], + "symbol_stile": [ + "Kontur_Hauptwirtschaftsweg", + "Decker_Hauptwirtschaftsweg" + ] + }, + { + "schluessel": "verkehr_klasse_kreisstrasse", + "bezeichnung": "Kreisstraße", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Kreisstr", + "Kontur_Kreisstr_FSZ1", + "Kontur_Kreisstr", + "Kontur_Kreisstr_getrennt", + "Kontur_Kreisstr_getrennt_Fahrbahnachse", + "Decker_Kreisstr_FSZ1", + "Decker_Kreisstr", + "Decker_Kreisstr_getrennt", + "Decker_Kreisstr_getrennt_Fahrbahnachse", + "Bruecke_Kreisstr_FSZ1_Kontur", + "Bruecke_Kreisstr_Kontur", + "Bruecke_Kreisstr_getrennt_Kontur", + "Bruecke_Kreisstr_getrennt_Fahrbahnachse_Kontur", + "Bruecke_Kreisstr_FSZ1_Decker", + "Bruecke_Kreisstr_Decker", + "Bruecke_Kreisstr_getrennt_Decker", + "Bruecke_Kreisstr_getrennt_Fahrbahnachse_Decker" + ], + "symbol_stile": [ + "Kontur_Kreisstr", + "Decker_Kreisstr" + ] + }, + { + "schluessel": "verkehr_klasse_landesstrasse_staatsstrasse", + "bezeichnung": "Landesstraße, Staatsstraße", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Landesstr", + "Kontur_Landesstr_Staatsstr_FSZ1", + "Kontur_Landesstr_Staatsstr", + "Kontur_Landesstr_Staatsstr_getrennt", + "Kontur_Landesstr_Staatsstr_getrennt_Fahrbahnachse", + "Decker_Landesstr_Staatsstr_FSZ1", + "Decker_Landesstr_Staatsstr_ohne_Tunnel_Bruecke", + "Decker_Landesstr_Staatsstr", + "Decker_Landesstr_Staatsstr_getrennt_ohne_Tunnel_Bruecke", + "Decker_Landesstr_Staatsstr_getrennt", + "Decker_Landesstr_Staatsstr_getrennt_Fahrbahnachse", + "Bruecke_Landesstr_Staatsstr_FSZ1_Kontur", + "Bruecke_Landesstr_Staatsstr_Kontur", + "Bruecke_Landesstr_Staatsstr_getrennt_Kontur", + "Bruecke_Landesstr_Staatsstr_getrennt_Fahrbahnachse_Kontur", + "Bruecke_Landesstr_Staatsstr_FSZ1_Decker", + "Bruecke_Landesstr_Staatsstr_Decker", + "Bruecke_Landesstr_Staatsstr_getrennt_Decker", + "Bruecke_Landesstr_Staatsstr_getrennt_Fahrbahnachse_Decker" + ], + "symbol_stile": [ + "Kontur_Landesstr_Staatsstr", + "Decker_Landesstr_Staatsstr" + ] + }, + { + "schluessel": "verkehr_klasse_bundesstrasse", + "bezeichnung": "Bundesstraße", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Bundesstr", + "Kontur_Bundesstr_FSZ1", + "Kontur_Bundesstr", + "Kontur_Bundesstr_getrennt", + "Kontur_Bundesstr_getrennt_Fahrbahnachse", + "Decker_Bundesstr_FSZ1", + "Decker_Bundesstr_ohne_Tunnel_Bruecke_FSZ1", + "Decker_Bundesstr_ohne_Tunnel_Bruecke", + "Decker_Bundesstr_getrennt_ohne_Tunnel_Bruecke", + "Decker_Bundesstr_getrennt", + "Decker_Bundesstr", + "Decker_Bundesstr_getrennt_Fahrbahnachse", + "Bruecke_Bundesstr_FSZ1_Kontur", + "Bruecke_Bundesstr_Kontur", + "Bruecke_Bundesstr_getrennt_Kontur", + "Bruecke_Bundesstr_getrennt_Fahrbahnachse_Kontur", + "Bruecke_Bundesstr_FSZ1_Decker", + "Bruecke_Bundesstr_Decker", + "Bruecke_Bundesstr_getrennt_Decker", + "Bruecke_Bundesstr_getrennt_Fahrbahnachse_Decker" + ], + "symbol_stile": [ + "Kontur_Bundesstr", + "Decker_Bundesstr" + ] + }, + { + "schluessel": "verkehr_fusswege", + "bezeichnung": "Fuß-, Rad- und Reitweg, Pfad", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Fussweg", + "Kontur_Fusswege", + "Bruecke_Fusswege_Kontur", + "Bruecke_Fusswege_Decker", + "Bruecke_Fusswege", + "Tunnel_Fusswege" + ], + "symbol_stile": [ + "Kontur_Fusswege" + ] + }, + { + "schluessel": "verkehr_imbau_ausserbetrieb_bahnstrecke_eisenbahn", + "bezeichnung": "Eisenbahn im Bau / außer Betrieb", + "gruppe": "Verkehr", + "treffer_stile": [ + "ImBau_AusserBetrieb_Bahnstrecke_Eisenbahn", + "ImBau_AusserBetrieb_Bruecke_Bahnstrecke_Eisenbahn" + ], + "symbol_stile": [ + "ImBau_AusserBetrieb_Bahnstrecke_Eisenbahn" + ] + }, + { + "schluessel": "verkehr_imbau_ausserbetrieb_bahnstrecke_s_bahn", + "bezeichnung": "S-Bahn im Bau / außer Betrieb", + "gruppe": "Verkehr", + "treffer_stile": [ + "ImBau_AusserBetrieb_Bahnstrecke_S_Bahn" + ], + "symbol_stile": [ + "ImBau_AusserBetrieb_Bahnstrecke_S_Bahn" + ] + }, + { + "schluessel": "verkehr_klasse_bundesautobahn", + "bezeichnung": "Autobahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Autobahn", + "Kontur_Autobahn_Abfahrt", + "Kontur_Autobahn_getrennt", + "Kontur_Autobahn_getrennt_Fahrbahnachse", + "Decker_Autobahn_Abfahrt_einblenden", + "Decker_Autobahn_Abfahrt", + "Decker_Autobahn_getrennt_ohne_Tunnel_Bruecke", + "Decker_Autobahn_getrennt", + "Decker_Autobahn_getrennt_Fahrbahnachse", + "Mittellinie_Autobahn", + "Bruecke_Autobahn_Abfahrt_Kontur", + "Bruecke_Autobahn_getrennt_Kontur", + "Bruecke_Autobahn_getrennt_Fahrbahnachse_Kontur", + "Bruecke_Autobahn_Abfahrt_Decker", + "Bruecke_Autobahn_getrennt_Decker", + "Bruecke_Autobahn_getrennt_Fahrbahnachse_Decker", + "Bruecke_Mittellinie_Autobahn" + ], + "symbol_stile": [ + "Kontur_Autobahn_getrennt", + "Decker_Autobahn_getrennt" + ] + }, + { + "schluessel": "verkehr_gemeindestr_sonstige_str", + "bezeichnung": "Gemeindestraße, sonstige Straße", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Gemeindestr", + "Staumauer_Staudamm_Gemeindestr_SonstStr_getrennt", + "Staumauer_Staudamm_Gemeindestr_SonstStr_getr_Fahrbahnachse", + "Kontur_Gemeindestr_Sonstige_Str", + "Kontur_Gemeindestr_Sonstige_Str_getrennt", + "Kontur_Gemeindestr_Sonstige_Str_getrennt_Fahrbahnachse", + "Decker_Gemeindestr_Sonstige_Str", + "Decker_Gemeindestr_Sonstige_Str_getrennt", + "Decker_Gemeindestr_Sonstige_Str_getrennt_Fahrbahnachse", + "Bruecke_Gemeindestr_Sonstige_Str_Kontur", + "Bruecke_Gemeindestr_Sonstige_Str_getrennt_Kontur", + "Bruecke_Gemeindestr_Sonstige_Str_getrennt_Fahrbahnachse_Kontur", + "Bruecke_Gemeindestr_Sonstige_Str_Decker", + "Bruecke_Gemeindestr_Sonstige_Str_getrennt_Decker", + "Bruecke_Gemeindestr_Sonstige_Str_getrennt_Fahrbahnachse_Decker" + ], + "symbol_stile": [ + "Kontur_Gemeindestr_Sonstige_Str", + "Decker_Gemeindestr_Sonstige_Str" + ] + }, + { + "schluessel": "verkehr_gemeindestr_sonstige_str_durchgangsverkehr", + "bezeichnung": "Gemeindestraße mit Durchgangsverkehr", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Gemeindestr_SonstStr_Durchgangsverkehr", + "Kontur_Gemeindestr_Sonstige_Str_Durchgangsverkehr", + "Decker_Gemeindestr_Sonstige_Str_Durchgangsverkehr", + "Bruecke_Gemeindestr_Sonstige_Str_Durchgangsverkehr_Kontur", + "Bruecke_Gemeindestr_Sonstige_Str_Durchgangsverkehr_Decker" + ], + "symbol_stile": [ + "Kontur_Gemeindestr_Sonstige_Str_Durchgangsverkehr", + "Decker_Gemeindestr_Sonstige_Str_Durchgangsverkehr" + ] + }, + { + "schluessel": "verkehr_imbau_strasse", + "bezeichnung": "Straße im Bau", + "gruppe": "Verkehr", + "treffer_stile": [ + "ImBau_AusserBetrieb_Kontur_Strasse_einbahnig", + "ImBau_AusserBetrieb_Kontur_Strasse_getrennt_Fahrbahnachse", + "ImBau_AusserBetrieb_Kontur_Strasse_getrennt", + "ImBau_Decker_Strasse_einbahnig", + "ImBau_Decker_Strasse_getrennt_Fahrbahnachse", + "ImBau_Decker_Strasse_getrennt", + "ImBau_AusserBetrieb_Bruecke_Strasse_einbahnig_Kontur", + "ImBau_AusserBetrieb_Bruecke_Strasse_getrennt_Fahrbahnachse_Kontur", + "ImBau_AusserBetrieb_Bruecke_Strasse_getrennt_Kontur", + "ImBau_Bruecke_Strasse_einbahnig_Decker", + "ImBau_Bruecke_Strasse_getrennt_Fahrbahnachse_Decker", + "ImBau_Bruecke_Strasse_getrennt_Decker" + ], + "symbol_stile": [ + "ImBau_AusserBetrieb_Kontur_Strasse_einbahnig", + "ImBau_Decker_Strasse_einbahnig" + ] + }, + { + "schluessel": "verkehr_ausserbetrieb_strasse", + "bezeichnung": "Straße außer Betrieb", + "gruppe": "Verkehr", + "treffer_stile": [ + "AusserBetrieb_Decker_Strasse_einbahnig", + "AusserBetrieb_Decker_Strasse_getrennt_Fahrbahnachse", + "AusserBetrieb_Decker_Strasse_getrennt", + "AusserBetrieb_Bruecke_Strasse_einbahnig_Decker", + "AusserBetrieb_Bruecke_Strasse_getrennt_Fahrbahnachse_Decker", + "AusserBetrieb_Bruecke_Strasse_getrennt_Decker" + ], + "symbol_stile": [ + "ImBau_AusserBetrieb_Kontur_Strasse_einbahnig", + "AusserBetrieb_Decker_Strasse_einbahnig" + ] + }, + { + "schluessel": "verkehr_klasse_gleis", + "bezeichnung": "Gleis", + "gruppe": "Verkehr", + "treffer_stile": [ + "Bahnstrecke_Gleis" + ], + "symbol_stile": [ + "Bahnstrecke_Gleis" + ] + }, + { + "schluessel": "verkehr_klasse_eisenbahn_gueterverkehr_personenverkehr", + "bezeichnung": "Eisenbahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "Staumauer_Staudamm_Bahnstrecke", + "Bahnstrecke_Eisenbahn_einblenden", + "Bahnstrecke_Eisenbahn_einblenden_Normalspur", + "Bahnstrecke_Eisenbahn", + "Bruecke_Bahnstrecke_Kontur", + "Bruecke_Bahnstrecke_Decker", + "Bruecke_Bahnstrecke_Eisenbahn", + "Tunnel_Bahnstrecke_Eisenbahn" + ], + "symbol_stile": [ + "Bahnstrecke_Eisenbahn" + ] + }, + { + "schluessel": "verkehr_bahnstrecke_sonderbahnen", + "bezeichnung": "Sonderbahn (Standseilbahn, Museumsbahn …)", + "gruppe": "Verkehr", + "treffer_stile": [ + "Bahnstrecke_Sonderbahnen" + ], + "symbol_stile": [ + "Bahnstrecke_Sonderbahnen" + ] + }, + { + "schluessel": "verkehr_klasse_stadtbahn_strassenbahn", + "bezeichnung": "Stadtbahn / Straßenbahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "Bahnstrecke_Stadtbahn", + "Bruecke_Bahnstrecke_Stadtbahn", + "Tunnel_Bahnstrecke_Stadtbahn" + ], + "symbol_stile": [ + "Bahnstrecke_Stadtbahn" + ] + }, + { + "schluessel": "verkehr_klasse_u_bahn", + "bezeichnung": "U-Bahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "Bahnstrecke_U_Bahn", + "Bruecke_Bahnstrecke_U_Bahn", + "Tunnel_Bahnstrecke_U_Bahn" + ], + "symbol_stile": [ + "Bahnstrecke_U_Bahn" + ] + }, + { + "schluessel": "verkehr_klasse_s_bahn", + "bezeichnung": "S-Bahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "Bahnstrecke_S_Bahn", + "Bruecke_Bahnstrecke_S_Bahn", + "Tunnel_Bahnstrecke_S_Bahn" + ], + "symbol_stile": [ + "Bahnstrecke_S_Bahn" + ] + }, + { + "schluessel": "verkehr_verkehrf_flughafen", + "bezeichnung": "Flughafen", + "gruppe": "Verkehr", + "treffer_stile": [ + "Bruecke_VerkehrF_Flughafen_Decker", + "Symbol_VerkehrF_Flughafen" + ], + "symbol_stile": [ + "Symbol_VerkehrF_Flughafen" + ] + }, + { + "schluessel": "verkehr_verkehrsl_seilbahnschwebebahn", + "bezeichnung": "Seilbahn, Schwebebahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "VerkehrsL_SeilbahnSchwebebahn_Linie", + "VerkehrsL_SeilbahnSchwebebahn_Punkte" + ], + "symbol_stile": [ + "VerkehrsL_SeilbahnSchwebebahn_Linie", + "VerkehrsL_SeilbahnSchwebebahn_Punkte" + ] + }, + { + "schluessel": "verkehr_klasse_materialseilbahn", + "bezeichnung": "Materialseilbahn", + "gruppe": "Verkehr", + "treffer_stile": [ + "VerkehrsL_Materialseilbahn_Linie", + "VerkehrsL_Materialseilbahn_Punkte" + ], + "symbol_stile": [ + "VerkehrsL_Materialseilbahn_Linie", + "VerkehrsL_Materialseilbahn_Punkte" + ] + }, + { + "schluessel": "verkehr_klasse_sessellift", + "bezeichnung": "Sessellift", + "gruppe": "Verkehr", + "treffer_stile": [ + "VerkehrsL_Sessellift_Enden", + "VerkehrsL_Sessellift_Linie" + ], + "symbol_stile": [ + "VerkehrsL_Sessellift_Enden", + "VerkehrsL_Sessellift_Linie" + ] + }, + { + "schluessel": "verkehr_klasse_ski_schlepplift", + "bezeichnung": "Ski-, Schlepplift", + "gruppe": "Verkehr", + "treffer_stile": [ + "VerkehrsL_Ski_Schlepplift_Enden", + "VerkehrsL_Ski_Schlepplift_Linie" + ], + "symbol_stile": [ + "VerkehrsL_Ski_Schlepplift_Enden", + "VerkehrsL_Ski_Schlepplift_Linie" + ] + }, + { + "schluessel": "verkehr_strasse", + "bezeichnung": "Straße im Tunnel, Unterführung", + "gruppe": "Verkehr", + "treffer_stile": [ + "Tunnel_Strasse", + "Tunnel_Strasse_Fahrbahnachse" + ], + "symbol_stile": [ + "Tunnel_Strasse" + ] + }, + { + "schluessel": "verkehr_klasse_parkplatz_rastplatz_raststaette_autohof", + "bezeichnung": "Parkplatz, Rastplatz", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_IwnF_Parkplatz", + "Symbol_VerkehrF_Parkplatz" + ], + "symbol_stile": [ + "Symbol_VerkehrF_Parkplatz" + ] + }, + { + "schluessel": "verkehr_klasse_segelfluggelaende", + "bezeichnung": "Segelfluggelände", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrF_Segelfluggelaende" + ], + "symbol_stile": [ + "Symbol_VerkehrF_Segelfluggelaende" + ] + }, + { + "schluessel": "verkehr_klasse_hubschrauberlandeplatz", + "bezeichnung": "Hubschrauberlandeplatz", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrF_Hubschrauberlandeplatz", + "Symbol_VerkehrP_Hubschrauberlandeplatz" + ], + "symbol_stile": [ + "Symbol_VerkehrF_Hubschrauberlandeplatz" + ] + }, + { + "schluessel": "verkehr_verkehrp_strassenbahnhaltestelle", + "bezeichnung": "Straßenbahnhaltestelle", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrP_Strassenbahnhaltestelle" + ], + "symbol_stile": [ + "Symbol_VerkehrP_Strassenbahnhaltestelle" + ] + }, + { + "schluessel": "verkehr_klasse_haltepunkt_haltestelle", + "bezeichnung": "Haltepunkt, Haltestelle", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrP_Haltepunkt" + ], + "symbol_stile": [ + "Symbol_VerkehrP_Haltepunkt" + ] + }, + { + "schluessel": "verkehr_klasse_u_bahn_hp_u_bahn_hs", + "bezeichnung": "U-Bahn-Haltestelle", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrP_U_Bahn" + ], + "symbol_stile": [ + "Symbol_VerkehrP_U_Bahn" + ] + }, + { + "schluessel": "verkehr_klasse_s_bahn_hp_s_bahn_hs", + "bezeichnung": "S-Bahn-Haltestelle", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrP_S_Bahn" + ], + "symbol_stile": [ + "Symbol_VerkehrP_S_Bahn" + ] + }, + { + "schluessel": "verkehr_klasse_bahnhof", + "bezeichnung": "Bahnhof", + "gruppe": "Verkehr", + "treffer_stile": [ + "Symbol_VerkehrP_Bahnhof", + "Symbol_VerkehrF_Bahnhof" + ], + "symbol_stile": [ + "Symbol_VerkehrF_Bahnhof" + ] + }, + { + "schluessel": "gewaesser_gewaesser_breite", + "bezeichnung": "Fließgewässer (Linie)", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_Breite_3m", + "Gewaesser_L_Breite_6m", + "Gewaesser_L_Breite_12m", + "Gewaesser_L_Breite_18m", + "Gewaesser_L_Breite_30m", + "Gewaesser_L_Breite_42m", + "Gewaesser_L_Breite_125m", + "Gewaesser_L_Breite_200m" + ], + "symbol_stile": [ + "Gewaesser_L_Breite_6m" + ] + }, + { + "schluessel": "gewaesser_gewaesser_breite_nicht_staendig_wasserfuehrend", + "bezeichnung": "Fließgewässer, nicht ständig wasserführend", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_Breite_3m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_6m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_12m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_18m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_30m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_42m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_125m_nicht_staendig_wasserfuehrend", + "Gewaesser_L_Breite_200m_nicht_staendig_wasserfuehrend" + ], + "symbol_stile": [ + "Gewaesser_L_Breite_6m_nicht_staendig_wasserfuehrend" + ] + }, + { + "schluessel": "gewaesser_gewaesser_durchlass_dueker", + "bezeichnung": "Durchlass, Düker", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_Durchlass_Dueker" + ], + "symbol_stile": [ + "Gewaesser_L_Durchlass_Dueker" + ] + }, + { + "schluessel": "gewaesser_klasse_sickerstrecke", + "bezeichnung": "Sickerstrecke", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_Sickerstrecke" + ], + "symbol_stile": [ + "Gewaesser_L_Sickerstrecke" + ] + }, + { + "schluessel": "gewaesser_klasse_meer", + "bezeichnung": "Meer", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_Meer", + "Gewaesser_F_Meer" + ], + "symbol_stile": [ + "Gewaesser_L_Meer" + ] + }, + { + "schluessel": "gewaesser_gewaesser_fliessgewaesser", + "bezeichnung": "Fließgewässer (Fläche)", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_Fliessgewaesser", + "Gewaesser_F_Fliessgewaesser" + ], + "symbol_stile": [ + "Gewaesser_L_Fliessgewaesser" + ] + }, + { + "schluessel": "gewaesser_gewaesser_see_hafenbecken", + "bezeichnung": "Stehendes Gewässer, Hafenbecken", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_L_See_Hafenbecken", + "Gewaesser_F_See_Hafenbecken" + ], + "symbol_stile": [ + "Gewaesser_L_See_Hafenbecken" + ] + }, + { + "schluessel": "gewaesser_klasse_watt", + "bezeichnung": "Watt", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_F_Watt" + ], + "symbol_stile": [ + "Gewaesser_F_Watt" + ] + }, + { + "schluessel": "gewaesser_klasse_priel", + "bezeichnung": "Priel", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_F_Priel" + ], + "symbol_stile": [ + "Gewaesser_F_Priel" + ] + }, + { + "schluessel": "gewaesser_klasse_sandbank", + "bezeichnung": "Sandbank", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_F_Sandbank" + ], + "symbol_stile": [ + "Gewaesser_F_Sandbank" + ] + }, + { + "schluessel": "gewaesser_klasse_quelle", + "bezeichnung": "Quelle", + "gruppe": "Gewässer", + "treffer_stile": [ + "Symbol_GewasserP_Quelle" + ], + "symbol_stile": [ + "Symbol_GewasserP_Quelle" + ] + }, + { + "schluessel": "gewaesser_klasse_pegel", + "bezeichnung": "Pegel", + "gruppe": "Gewässer", + "treffer_stile": [ + "Symbol_GewasserP_Pegel" + ], + "symbol_stile": [ + "Symbol_GewasserP_Pegel" + ] + }, + { + "schluessel": "gewaesser_klasse_wasserfall", + "bezeichnung": "Wasserfall", + "gruppe": "Gewässer", + "treffer_stile": [ + "Gewaesser_F_Quelle_Wasserfall", + "Symbol_GewaesserF_Wasserfall", + "Symbol_GewaesserL_Wasserfall", + "Symbol_GewasserP_Wasserfall" + ], + "symbol_stile": [ + "Symbol_GewaesserF_Wasserfall" + ] + }, + { + "schluessel": "gewaesser_klasse_hoehe_wasserspiegel", + "bezeichnung": "Wasserspiegelhöhe", + "gruppe": "Gewässer", + "treffer_stile": [ + "Symbol_GewasserP_Wasserspiegel" + ], + "symbol_stile": [ + "Symbol_GewasserP_Wasserspiegel" + ] + }, + { + "schluessel": "vegetation_vegetationsf_vegetationsloseflaeche_fels_geroell_stein", + "bezeichnung": "Fels, Geröll, Stein", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_VegetationsloseFlaeche_Fels_Geroell_Stein", + "Bruecke_VegetationsF_VegetationsloseFlaeche" + ], + "symbol_stile": [ + "VegetationsF_VegetationsloseFlaeche_Fels_Geroell_Stein" + ] + }, + { + "schluessel": "vegetation_klasse_eis_firn", + "bezeichnung": "Eis, Firn", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_VegetationsloseFlaeche_Eis" + ], + "symbol_stile": [ + "VegetationsF_VegetationsloseFlaeche_Eis" + ] + }, + { + "schluessel": "vegetation_klasse_sand", + "bezeichnung": "Sand", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_VegetationsloseFlaeche_Sand" + ], + "symbol_stile": [ + "VegetationsF_VegetationsloseFlaeche_Sand" + ] + }, + { + "schluessel": "vegetation_vegetationsf_ackerland_und_co", + "bezeichnung": "Ackerland", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Ackerland_und_Co" + ], + "symbol_stile": [ + "VegetationsF_Ackerland_und_Co" + ] + }, + { + "schluessel": "vegetation_klasse_gras_gruenland_streuobst_streuobstwiese", + "bezeichnung": "Grünland", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Gruenland", + "Bruecke_VegetationsF_Gruenland", + "Bruecke_VegetationsL_Gruenland" + ], + "symbol_stile": [ + "VegetationsF_Gruenland" + ] + }, + { + "schluessel": "vegetation_klasse_gartenbauland", + "bezeichnung": "Gartenland", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Gartenland" + ], + "symbol_stile": [ + "VegetationsF_Gartenland" + ] + }, + { + "schluessel": "vegetation_klasse_heide", + "bezeichnung": "Heide", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Heide" + ], + "symbol_stile": [ + "VegetationsF_Heide" + ] + }, + { + "schluessel": "vegetation_klasse_moor_sumpf", + "bezeichnung": "Moor / Sumpf", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Moor_Sumpf" + ], + "symbol_stile": [ + "VegetationsF_Moor_Sumpf" + ] + }, + { + "schluessel": "vegetation_klasse_bewuchs_gehoelz_gebuesch_gehoelz", + "bezeichnung": "Gehölz", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Gehoelz", + "Bruecke_VegetationsF_Gehoelz" + ], + "symbol_stile": [ + "VegetationsF_Gehoelz" + ] + }, + { + "schluessel": "vegetation_vegetationsf_wald", + "bezeichnung": "Wald", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsF_Wald", + "Bruecke_VegetationsF_Wald" + ], + "symbol_stile": [ + "VegetationsF_Wald" + ] + }, + { + "schluessel": "vegetation_vegetationsl_baumreihe", + "bezeichnung": "Baumreihe", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsL_Baumreihe", + "VegetationsL_Baumreihe_Fuellung" + ], + "symbol_stile": [ + "VegetationsL_Baumreihe", + "VegetationsL_Baumreihe_Fuellung" + ] + }, + { + "schluessel": "vegetation_klasse_hecke", + "bezeichnung": "Hecke", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsL_Hecke" + ], + "symbol_stile": [ + "VegetationsL_Hecke" + ] + }, + { + "schluessel": "vegetation_klasse_schneise", + "bezeichnung": "Schneise", + "gruppe": "Vegetation", + "treffer_stile": [ + "VegetationsL_Schneise", + "VegetationsF_Schneise" + ], + "symbol_stile": [ + "VegetationsL_Schneise" + ] + }, + { + "schluessel": "vegetation_klasse_laubbaum", + "bezeichnung": "Laubbaum", + "gruppe": "Vegetation", + "treffer_stile": [ + "Symbol_VegetationP_Laubbaum" + ], + "symbol_stile": [ + "Symbol_VegetationP_Laubbaum" + ] + }, + { + "schluessel": "vegetation_klasse_nadelbaum", + "bezeichnung": "Nadelbaum", + "gruppe": "Vegetation", + "treffer_stile": [ + "Symbol_VegetationP_Nadelbaum" + ], + "symbol_stile": [ + "Symbol_VegetationP_Nadelbaum" + ] + }, + { + "schluessel": "siedlung_klasse_tagebau_grube_steinbruch", + "bezeichnung": "Tagebau, Grube, Steinbruch", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_TagebauGrubeSteinbruch" + ], + "symbol_stile": [ + "SiedlungF_TagebauGrubeSteinbruch" + ] + }, + { + "schluessel": "siedlung_klasse_bergbau", + "bezeichnung": "Bergbau", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_Bergbau" + ], + "symbol_stile": [ + "SiedlungF_Bergbau" + ] + }, + { + "schluessel": "siedlung_siedlungf_sportfreizeitunderholung", + "bezeichnung": "Sport, Freizeit und Erholung", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_SportFreizeitundErholung", + "Bruecke_SiedlungF_SportFreizeitundErholung" + ], + "symbol_stile": [ + "SiedlungF_SportFreizeitundErholung" + ] + }, + { + "schluessel": "siedlung_klasse_friedhof_parkfriedhof", + "bezeichnung": "Friedhof", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_Friedhof" + ], + "symbol_stile": [ + "SiedlungF_Friedhof" + ] + }, + { + "schluessel": "siedlung_klasse_halde", + "bezeichnung": "Halde", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_Halde" + ], + "symbol_stile": [ + "SiedlungF_Halde" + ] + }, + { + "schluessel": "siedlung_siedlungf_industrie_und_gewerbe", + "bezeichnung": "Industrie und Gewerbe", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_Industrie_und_Gewerbe", + "Bruecke_SiedlungF_Industrie_und_Gewerbe" + ], + "symbol_stile": [ + "SiedlungF_Industrie_und_Gewerbe" + ] + }, + { + "schluessel": "siedlung_siedlungf_siedlung", + "bezeichnung": "Siedlungsfläche", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_Siedlung_Industrie", + "SiedlungF_Siedlung", + "Bruecke_SiedlungF_Siedlung_Industrie", + "Bruecke_SiedlungF_Siedlung" + ], + "symbol_stile": [ + "SiedlungF_Siedlung" + ] + }, + { + "schluessel": "siedlung_siedlungf_forstfischerei", + "bezeichnung": "Fischereiwirtschaftsfläche", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "SiedlungF_ForstFischerei" + ], + "symbol_stile": [ + "SiedlungF_ForstFischerei" + ] + }, + { + "schluessel": "siedlung_istweiterenutzung_flaeche_platz", + "bezeichnung": "Platz", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "IstWeitereNutzung_Flaeche_Platz" + ], + "symbol_stile": [ + "IstWeitereNutzung_Flaeche_Platz" + ] + }, + { + "schluessel": "siedlung_klasse_umspannstation", + "bezeichnung": "Umspannwerk", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "Symbol_SiedlungF_Umspannwerk" + ], + "symbol_stile": [ + "Symbol_SiedlungF_Umspannwerk" + ] + }, + { + "schluessel": "siedlung_klasse_kraftwerk", + "bezeichnung": "Kraftwerk", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "Symbol_SiedlungF_Kraftwerk" + ], + "symbol_stile": [ + "Symbol_SiedlungF_Kraftwerk" + ] + }, + { + "schluessel": "siedlung_art_sonne_klasse_kraftwerk", + "bezeichnung": "Solarkraftwerk", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "Symbol_SiedlungF_Kraftwerk_Solar" + ], + "symbol_stile": [ + "Symbol_SiedlungF_Kraftwerk_Solar" + ] + }, + { + "schluessel": "siedlung_art_wasser_klasse_kraftwerk", + "bezeichnung": "Wasserkraftwerk", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "Symbol_SiedlungF_Kraftwerk_Wasser" + ], + "symbol_stile": [ + "Symbol_SiedlungF_Kraftwerk_Wasser" + ] + }, + { + "schluessel": "siedlung_klasse_campingplatz", + "bezeichnung": "Campingplatz", + "gruppe": "Siedlung und Nutzung", + "treffer_stile": [ + "Symbol_SiedlungF_Campingplatz" + ], + "symbol_stile": [ + "Symbol_SiedlungF_Campingplatz" + ] + }, + { + "schluessel": "gebaeude_gebaeude2d_nicht_oeffentlich", + "bezeichnung": "Gebäude", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeude2D_nicht_oeffentlich" + ], + "symbol_stile": [ + "Gebaeude2D_nicht_oeffentlich" + ] + }, + { + "schluessel": "gebaeude_gebaeude2d_treibhaus", + "bezeichnung": "Gewächshaus", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeude2D_Treibhaus", + "Gebaeude2D_Treibhaus_Kontur" + ], + "symbol_stile": [ + "Gebaeude2D_Treibhaus", + "Gebaeude2D_Treibhaus_Kontur" + ] + }, + { + "schluessel": "gebaeude_gebaeude2d_oeffentlich", + "bezeichnung": "Öffentliches Gebäude", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeude2D_oeffentlich" + ], + "symbol_stile": [ + "Gebaeude2D_oeffentlich" + ] + }, + { + "schluessel": "gebaeude_klasse_tiefgarage", + "bezeichnung": "Tiefgarage", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeude_Tiefgarage", + "Gebaeude_Tiefgarage_Kontur" + ], + "symbol_stile": [ + "Gebaeude_Tiefgarage", + "Gebaeude_Tiefgarage_Kontur" + ] + }, + { + "schluessel": "gebaeude_klasse_windmuehle", + "bezeichnung": "Windmühle", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Windmuehle" + ], + "symbol_stile": [ + "Gebaeudepunkt_Windmuehle" + ] + }, + { + "schluessel": "gebaeude_klasse_parkhaus", + "bezeichnung": "Parkhaus", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Parkhaus" + ], + "symbol_stile": [ + "Gebaeudepunkt_Parkhaus" + ] + }, + { + "schluessel": "gebaeude_klasse_hallenbad_schwimmen", + "bezeichnung": "Schwimmbad", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Schwimmbad" + ], + "symbol_stile": [ + "Gebaeudepunkt_Schwimmbad" + ] + }, + { + "schluessel": "gebaeude_gebaeudepunkt_bildung_forschung", + "bezeichnung": "Bildung und Forschung", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Bildung_Forschung" + ], + "symbol_stile": [ + "Gebaeudepunkt_Bildung_Forschung" + ] + }, + { + "schluessel": "gebaeude_klasse_freilichtmuseum_museum", + "bezeichnung": "Museum", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Museum" + ], + "symbol_stile": [ + "Gebaeudepunkt_Museum" + ] + }, + { + "schluessel": "gebaeude_klasse_polizei", + "bezeichnung": "Polizei", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Polizei" + ], + "symbol_stile": [ + "Gebaeudepunkt_Polizei" + ] + }, + { + "schluessel": "gebaeude_klasse_feuerwehr", + "bezeichnung": "Feuerwehr", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Feuerwehr" + ], + "symbol_stile": [ + "Gebaeudepunkt_Feuerwehr" + ] + }, + { + "schluessel": "gebaeude_klasse_krankenhaus", + "bezeichnung": "Krankenhaus", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Krankenhaus" + ], + "symbol_stile": [ + "Gebaeudepunkt_Krankenhaus" + ] + }, + { + "schluessel": "gebaeude_klasse_burg_festung_schloss", + "bezeichnung": "Burg, Schloss", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Schloss_Burg" + ], + "symbol_stile": [ + "Gebaeudepunkt_Schloss_Burg" + ] + }, + { + "schluessel": "gebaeude_klasse_kapelle", + "bezeichnung": "Kapelle", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Kapelle" + ], + "symbol_stile": [ + "Gebaeudepunkt_Kapelle" + ] + }, + { + "schluessel": "gebaeude_klasse_gotteshaus_moschee_synagoge_tempel", + "bezeichnung": "Gotteshaus (Moschee, Synagoge …)", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Gotteshaus" + ], + "symbol_stile": [ + "Gebaeudepunkt_Gotteshaus" + ] + }, + { + "schluessel": "gebaeude_klasse_kirche", + "bezeichnung": "Kirche", + "gruppe": "Gebäude", + "treffer_stile": [ + "Gebaeudepunkt_Kirche" + ], + "symbol_stile": [ + "Gebaeudepunkt_Kirche" + ] + }, + { + "schluessel": "bauwerk_klasse_hafendamm_mole", + "bezeichnung": "Hafendamm, Mole", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Hafendamm_Mole_Kontur", + "BauwerkL_Hafendamm_Mole_Kontur", + "BauwerkF_Hafendamm_Mole_Decker", + "BauwerkL_Hafendamm_Mole_Decker" + ], + "symbol_stile": [ + "BauwerkF_Hafendamm_Mole_Kontur", + "BauwerkF_Hafendamm_Mole_Decker" + ] + }, + { + "schluessel": "bauwerk_klasse_lahnung_wellenbrecher_buhne", + "bezeichnung": "Wellenbrecher, Buhne, Lahnung", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkL_Wellenbrecher_Buhne_Lahnung", + "BauwerkF_Wellenbrecher_Buhne_Lahnung" + ], + "symbol_stile": [ + "BauwerkL_Wellenbrecher_Buhne_Lahnung" + ] + }, + { + "schluessel": "bauwerk_bauwerkf_uferbefestigung_ufermauer_kaimauer_trockendock", + "bezeichnung": "Uferbefestigung, Ufermauer, Kaimauer", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Uferbefestigung_Ufermauer_Kaimauer_Trockendock_Kontur", + "BauwerkF_Uferbefestigung_Ufermauer_Kaimauer_Trockendock", + "BauwerkL_Uferbefestigung_Ufermauer-_Kaimauer" + ], + "symbol_stile": [ + "BauwerkF_Uferbefestigung_Ufermauer_Kaimauer_Trockendock_Kontur", + "BauwerkF_Uferbefestigung_Ufermauer_Kaimauer_Trockendock" + ] + }, + { + "schluessel": "bauwerk_klasse_anleger", + "bezeichnung": "Anleger", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Anleger_Kontur", + "BauwerkF_Anleger", + "BauwerkL_Anleger_L", + "Symbol_BauwerkF_Anleger", + "Symbol_BauwerkL_Anleger", + "Symbol_BauwerkP_Anleger" + ], + "symbol_stile": [ + "BauwerkF_Anleger_Kontur", + "BauwerkF_Anleger" + ] + }, + { + "schluessel": "bauwerk_klasse_sicherheitstor_sperrwerk", + "bezeichnung": "Sperrwerk, Sicherheitstor", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Sperrwerk_Sicherheitstor", + "BauwerkL_Sperrwerk_Sicherheitstor_Versatz", + "BauwerkL_Sperrwerk_Sicherheitstor" + ], + "symbol_stile": [ + "BauwerkF_Sperrwerk_Sicherheitstor", + "BauwerkL_Sperrwerk_Sicherheitstor_Versatz" + ] + }, + { + "schluessel": "bauwerk_klasse_wehr", + "bezeichnung": "Wehr", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Wehr_Versatz", + "BauwerkF_Wehr", + "BauwerkL_Wehr_Versatz", + "BauwerkL_Wehr" + ], + "symbol_stile": [ + "BauwerkF_Wehr_Versatz", + "BauwerkF_Wehr" + ] + }, + { + "schluessel": "bauwerk_klasse_schleusenkammer", + "bezeichnung": "Schleusenkammer", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Schleusenkammer", + "BauwerkL_Schleusenkammer" + ], + "symbol_stile": [ + "BauwerkF_Schleusenkammer" + ] + }, + { + "schluessel": "bauwerk_klasse_schoepfwerk", + "bezeichnung": "Schöpfwerk", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Schoepfwerk", + "BauwerkL_Schoepfwerk" + ], + "symbol_stile": [ + "BauwerkF_Schoepfwerk" + ] + }, + { + "schluessel": "bauwerk_klasse_rueckhaltebecken", + "bezeichnung": "Rückhaltebecken", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Rueckhaltebecken_Fuellung", + "BauwerkF_Rueckhaltebecken_Kontur" + ], + "symbol_stile": [ + "BauwerkF_Rueckhaltebecken_Fuellung", + "BauwerkF_Rueckhaltebecken_Kontur" + ] + }, + { + "schluessel": "bauwerk_klasse_hochofen", + "bezeichnung": "Hochofen", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Hochofen" + ], + "symbol_stile": [ + "BauwerkF_Hochofen" + ] + }, + { + "schluessel": "bauwerk_klasse_klaerbecken", + "bezeichnung": "Klärbecken", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Klaerbecken_Fuellung", + "BauwerkF_Klaerbecken_Kontur" + ], + "symbol_stile": [ + "BauwerkF_Klaerbecken_Fuellung", + "BauwerkF_Klaerbecken_Kontur" + ] + }, + { + "schluessel": "bauwerk_klasse_schornstein", + "bezeichnung": "Schornstein", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Schornstein_Schlot_Esse", + "Symbol_BauwerkP_Schornstein" + ], + "symbol_stile": [ + "BauwerkF_Schornstein_Schlot_Esse" + ] + }, + { + "schluessel": "bauwerk_klasse_solarzellen", + "bezeichnung": "Solarzellen", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Solarzellen", + "Symbol_BauwerkF_Kraftwerk_Solar" + ], + "symbol_stile": [ + "BauwerkF_Solarzellen" + ] + }, + { + "schluessel": "bauwerk_klasse_gradierwerk", + "bezeichnung": "Gradierwerk", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Gradierwerk", + "BauwerkL_Gradierwerk" + ], + "symbol_stile": [ + "BauwerkF_Gradierwerk" + ] + }, + { + "schluessel": "bauwerk_klasse_schwimmbecken", + "bezeichnung": "Schwimmbecken", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Schwimmbecken_Fuellung", + "BauwerkF_Schwimmbecken_Kontur" + ], + "symbol_stile": [ + "BauwerkF_Schwimmbecken_Fuellung", + "BauwerkF_Schwimmbecken_Kontur" + ] + }, + { + "schluessel": "bauwerk_bauwerkf_stadion_spielfeld_schiessanlage", + "bezeichnung": "Stadion, Spielfeld, Schießanlage", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Stadion_Spielfeld_Schiessanlage", + "BauwerkF_Stadion_Spielfeld_Schiessanlage_Kontur" + ], + "symbol_stile": [ + "BauwerkF_Stadion_Spielfeld_Schiessanlage", + "BauwerkF_Stadion_Spielfeld_Schiessanlage_Kontur" + ] + }, + { + "schluessel": "bauwerk_klasse_wildgehege", + "bezeichnung": "Wildgehege", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Wildgehege" + ], + "symbol_stile": [ + "BauwerkF_Wildgehege" + ] + }, + { + "schluessel": "bauwerk_bauwerkf_zuschauertribuene", + "bezeichnung": "Zuschauertribüne", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Zuschauertribuene", + "BauwerkL_Zuschauertribuene" + ], + "symbol_stile": [ + "BauwerkF_Zuschauertribuene" + ] + }, + { + "schluessel": "bauwerk_klasse_brunnen_brunnen_trinkwasserversorgung", + "bezeichnung": "Brunnen", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Brunnen", + "Symbol_BauwerksF_Brunnen", + "Symbol_BauwerkP_Brunnen" + ], + "symbol_stile": [ + "BauwerkF_Brunnen" + ] + }, + { + "schluessel": "bauwerk_klasse_radioteleskop", + "bezeichnung": "Radioteleskop", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkF_Radioteleskop", + "Symbol_BauwerkP_Radioteleskop" + ], + "symbol_stile": [ + "BauwerkF_Radioteleskop" + ] + }, + { + "schluessel": "bauwerk_klasse_schleuse_siel", + "bezeichnung": "Schleuse, Siel", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkL_Schleuse" + ], + "symbol_stile": [ + "BauwerkL_Schleuse" + ] + }, + { + "schluessel": "bauwerk_klasse_rennbahn_laufbahn_gelaeuf", + "bezeichnung": "Rennbahn, Laufbahn, Geläuf", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkL_Rennbahn_Laufbahn" + ], + "symbol_stile": [ + "BauwerkL_Rennbahn_Laufbahn" + ] + }, + { + "schluessel": "bauwerk_klasse_soda_steg", + "bezeichnung": "Soda-Steg", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Soda_Steg" + ], + "symbol_stile": [ + "Soda_Steg" + ] + }, + { + "schluessel": "bauwerk_klasse_verschlussbauwerk", + "bezeichnung": "Verschlussbauwerk", + "gruppe": "Bauwerke", + "treffer_stile": [ + "BauwerkL_Verschlussbauwerk" + ], + "symbol_stile": [ + "BauwerkL_Verschlussbauwerk" + ] + }, + { + "schluessel": "barrierenlinie_klasse_furt", + "bezeichnung": "Furt", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Barriere_Furt" + ], + "symbol_stile": [ + "Barriere_Furt" + ] + }, + { + "schluessel": "barrierenlinie_klasse_mauer", + "bezeichnung": "Mauer", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Barriere_Mauer_Kontur", + "Barriere_Mauer_Decker" + ], + "symbol_stile": [ + "Barriere_Mauer_Kontur", + "Barriere_Mauer_Decker" + ] + }, + { + "schluessel": "barrierenlinie_klasse_stuetzmauer", + "bezeichnung": "Stützmauer", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Barriere_Stuetzmauer_Kontur", + "Barriere_Stuetzmauer_Decker" + ], + "symbol_stile": [ + "Barriere_Stuetzmauer_Kontur", + "Barriere_Stuetzmauer_Decker" + ] + }, + { + "schluessel": "barrierenlinie_klasse_spundwand", + "bezeichnung": "Spundwand", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Barriere_Spundwand" + ], + "symbol_stile": [ + "Barriere_Spundwand" + ] + }, + { + "schluessel": "barrierenlinie_klasse_zaun", + "bezeichnung": "Zaun", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Barriere_Zaun_Kontur", + "Barriere_Zaun_Decker" + ], + "symbol_stile": [ + "Barriere_Zaun_Kontur", + "Barriere_Zaun_Decker" + ] + }, + { + "schluessel": "barrierenlinie_klasse_treppe", + "bezeichnung": "Treppe", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Kontur_Barriere_Treppe", + "Decker_Barriere_Treppe" + ], + "symbol_stile": [ + "Kontur_Barriere_Treppe", + "Decker_Barriere_Treppe" + ] + }, + { + "schluessel": "barrierenlinie_barriere_mauer_laermschutz_lv0", + "bezeichnung": "Lärmschutzwand", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Barriere_Mauer_Laermschutz_Lv0_Kontur", + "Barriere_Mauer_Laermschutz_Lv0_Decker", + "Barriere_Mauer_Laermschutz_Lv2_Kontur", + "Barriere_Mauer_Laermschutz_Lv2_Decker" + ], + "symbol_stile": [ + "Barriere_Mauer_Laermschutz_Lv0_Kontur", + "Barriere_Mauer_Laermschutz_Lv0_Decker" + ] + }, + { + "schluessel": "bauwerk_klasse_bruecke", + "bezeichnung": "Brücke", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Bruecken_AufKanal_GewFlaeche_Kontur", + "Bruecken_AufKanal_GewFlaeche_Decker", + "Bruecke_BauwerksFlaeche", + "Bruecke_Kanal_GewFlaeche" + ], + "symbol_stile": [ + "Bruecke_BauwerksFlaeche" + ] + }, + { + "schluessel": "bauwerk_klasse_soda_bruecke", + "bezeichnung": "Soda-Brücke", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Bruecke_Graben_BauwerksL_Kontur", + "Bruecke_Graben_BauwerksL_Decker" + ], + "symbol_stile": [ + "Bruecke_Graben_BauwerksL_Kontur", + "Bruecke_Graben_BauwerksL_Decker" + ] + }, + { + "schluessel": "bauwerk_soda", + "bezeichnung": "Soda-Brücke (außer Betrieb)", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Soda_Bruecke_Kontur", + "Soda_Bruecke_Decker" + ], + "symbol_stile": [ + "Soda_Bruecke_Kontur", + "Soda_Bruecke_Decker" + ] + }, + { + "schluessel": "bauwerk_soda_hebebruecke", + "bezeichnung": "Soda-Hebebrücke (außer Betrieb)", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Soda_Hebebruecke_Kontur", + "Soda_Hebebruecke_Decker", + "Soda_Hebebruecke_Mittellinie" + ], + "symbol_stile": [ + "Soda_Hebebruecke_Kontur", + "Soda_Hebebruecke_Decker" + ] + }, + { + "schluessel": "bauwerk_soda_unterfuehrung", + "bezeichnung": "Soda-Tunnel, Unterführung (außer Betrieb)", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Soda_Tunnel_Unterfuehrung" + ], + "symbol_stile": [ + "Soda_Tunnel_Unterfuehrung" + ] + }, + { + "schluessel": "bauwerk_klasse_kran", + "bezeichnung": "Kran", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkF_Kran_Symbol", + "Symbol_BauwerkL_Kran", + "Symbol_BauwerkP_Kran" + ], + "symbol_stile": [ + "Symbol_BauwerkF_Kran_Symbol" + ] + }, + { + "schluessel": "bauwerk_bauwerkf_vorratsbehaelter", + "bezeichnung": "Vorratsbehälter, Speicherbauwerk", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkF_Vorratsbehaelter", + "Symbol_BauwerkP_Vorratsbehaelter" + ], + "symbol_stile": [ + "Symbol_BauwerkF_Vorratsbehaelter" + ] + }, + { + "schluessel": "bauwerk_klasse_kirchturm_glockenturm", + "bezeichnung": "Kirchturm, Glockenturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerksF_Kirchturm_Glockenturm", + "Symbol_BauwerkP_Kirchturm_Glockenturm" + ], + "symbol_stile": [ + "Symbol_BauwerksF_Kirchturm_Glockenturm" + ] + }, + { + "schluessel": "bauwerk_klasse_schloss_burgturm_stadt_torturm", + "bezeichnung": "Schloss-, Burgturm / Stadt-, Torturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkF_Schlossturm_Burgturm_Stadtturm", + "Symbol_BauwerkP_Schlossturm_Burgturm_Stadtturm" + ], + "symbol_stile": [ + "Symbol_BauwerkF_Schlossturm_Burgturm_Stadtturm" + ] + }, + { + "schluessel": "bauwerk_klasse_feuerwachturm_kontrollturm", + "bezeichnung": "Feuerwachturm / Kontrollturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkF_Kontrollturm_Feuerwachturm", + "Symbol_BauwerkP_Kontrollturm_Feuerwachturm" + ], + "symbol_stile": [ + "Symbol_BauwerkF_Kontrollturm_Feuerwachturm" + ] + }, + { + "schluessel": "bauwerk_klasse_sende_funkturm_fernmeldeturm", + "bezeichnung": "Sende-, Funkturm, Fernmeldeturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerksF_Funk_Sendeturm", + "Symbol_BauwerkP_Funk_Sendeturm" + ], + "symbol_stile": [ + "Symbol_BauwerksF_Funk_Sendeturm" + ] + }, + { + "schluessel": "bauwerk_klasse_wasserturm", + "bezeichnung": "Wasserturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkF_Wasserturm", + "Symbol_BauwerksP_Wasserturm" + ], + "symbol_stile": [ + "Symbol_BauwerkF_Wasserturm" + ] + }, + { + "schluessel": "bauwerk_bauwerkf_aussichtsturm_foerderturm", + "bezeichnung": "Aussichtsturm, Förderturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkF_Aussichtsturm_Foerderturm", + "Symbol_BauwerkP_Aussichtsturm_Foerderturm" + ], + "symbol_stile": [ + "Symbol_BauwerkF_Aussichtsturm_Foerderturm" + ] + }, + { + "schluessel": "bauwerk_klasse_sprungschanze_anlauf", + "bezeichnung": "Sprungschanze (Anlauf)", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkL_Sprungschanze" + ], + "symbol_stile": [ + "Symbol_BauwerkL_Sprungschanze" + ] + }, + { + "schluessel": "bauwerk_klasse_antenne", + "bezeichnung": "Antenne", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Antenne" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Antenne" + ] + }, + { + "schluessel": "bauwerk_klasse_freileitungsmast", + "bezeichnung": "Freileitungsmast", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Freileitungsmast" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Freileitungsmast" + ] + }, + { + "schluessel": "bauwerk_klasse_funkmast", + "bezeichnung": "Funkmast", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Funkmast" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Funkmast" + ] + }, + { + "schluessel": "bauwerk_klasse_mast", + "bezeichnung": "Mast", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Mast" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Mast" + ] + }, + { + "schluessel": "bauwerk_klasse_schachtoeffnung", + "bezeichnung": "Schachtöffnung", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Schachtoeffnung" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Schachtoeffnung" + ] + }, + { + "schluessel": "bauwerk_klasse_stollenmundloch", + "bezeichnung": "Stollenmundloch", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Stollenmundloch" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Stollenmundloch" + ] + }, + { + "schluessel": "bauwerk_klasse_wasserrad", + "bezeichnung": "Wasserrad", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Wasserrad" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Wasserrad" + ] + }, + { + "schluessel": "bauwerk_klasse_windrad", + "bezeichnung": "Windrad", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Windrad" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Windrad" + ] + }, + { + "schluessel": "bauwerk_klasse_bake", + "bezeichnung": "Bake", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Bake" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Bake" + ] + }, + { + "schluessel": "bauwerk_klasse_leuchtfeuer", + "bezeichnung": "Leuchtfeuer", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Leuchtfeuer" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Leuchtfeuer" + ] + }, + { + "schluessel": "bauwerk_klasse_kilometerstein", + "bezeichnung": "Kilometerstein", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Kilometerstein" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Kilometerstein" + ] + }, + { + "schluessel": "bauwerk_bauwerkp_kellereingang", + "bezeichnung": "Kellereingang", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Kellereingang" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Kellereingang" + ] + }, + { + "schluessel": "bauwerk_klasse_leuchtturm", + "bezeichnung": "Leuchtturm", + "gruppe": "Bauwerke", + "treffer_stile": [ + "Symbol_BauwerkP_Leuchtturm" + ], + "symbol_stile": [ + "Symbol_BauwerkP_Leuchtturm" + ] + }, + { + "schluessel": "relief_klasse_duene", + "bezeichnung": "Düne", + "gruppe": "Relief", + "treffer_stile": [ + "ReliefF_Duene" + ], + "symbol_stile": [ + "ReliefF_Duene" + ] + }, + { + "schluessel": "relief_reliefl_daemme_deiche_verkehrsfuehrung", + "bezeichnung": "Damm, Deich ohne Verkehrsführung", + "gruppe": "Relief", + "treffer_stile": [ + "ReliefL_Daemme_Deiche_ohne_Verkehrsfuehrung" + ], + "symbol_stile": [ + "ReliefL_Daemme_Deiche_ohne_Verkehrsfuehrung" + ] + }, + { + "schluessel": "relief_reliefl_daemme_deiche_mit_verkehrsfuehrung_bis_hoehe", + "bezeichnung": "Damm, Deich mit Verkehrsführung", + "gruppe": "Relief", + "treffer_stile": [ + "ReliefL_Daemme_Deiche_mit_Verkehrsfuehrung_bis_3m_Hoehe", + "ReliefL_Daemme_Deiche_mit_Verkehrsfuehrung_bis_6m_Hoehe", + "ReliefL_Daemme_Deiche_mit_Verkehrsfuehrung_ueber_6m_Hoehe" + ], + "symbol_stile": [ + "ReliefL_Daemme_Deiche_mit_Verkehrsfuehrung_bis_3m_Hoehe" + ] + }, + { + "schluessel": "relief_klasse_einschnitt", + "bezeichnung": "Einschnitt", + "gruppe": "Relief", + "treffer_stile": [ + "ReliefL_Einschnitt" + ], + "symbol_stile": [ + "ReliefL_Einschnitt" + ] + }, + { + "schluessel": "relief_klasse_felsen_felsblock_felsnadel", + "bezeichnung": "Felsen, Felsblock, Felsnadel", + "gruppe": "Relief", + "treffer_stile": [ + "Symbol_ReliefF_Felsen", + "Symbol_ReliefL_Felsen", + "Symbol_ReliefP_Felsen" + ], + "symbol_stile": [ + "Symbol_ReliefF_Felsen" + ] + }, + { + "schluessel": "relief_klasse_hoehleneingang", + "bezeichnung": "Höhleneingang", + "gruppe": "Relief", + "treffer_stile": [ + "Symbol_ReliefP_Hoehleneingang" + ], + "symbol_stile": [ + "Symbol_ReliefP_Hoehleneingang" + ] + }, + { + "schluessel": "name_punkt_klasse_besonderer_hoehenpunkt", + "bezeichnung": "Höhenpunkt", + "gruppe": "Relief", + "treffer_stile": [ + "Symbol_Hoehenpunkt" + ], + "symbol_stile": [ + "Symbol_Hoehenpunkt" + ] + }, + { + "schluessel": "grenze_grenzen_gemeindegrenze", + "bezeichnung": "Gemeindegrenze", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "Grenzen_Gemeindegrenze" + ], + "symbol_stile": [ + "Grenzen_Gemeindegrenze" + ] + }, + { + "schluessel": "grenze_klasse_grenze_des_kreises_kreisfreien_stadt_region", + "bezeichnung": "Kreisgrenze", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "Grenzen_Kreisgrenze" + ], + "symbol_stile": [ + "Grenzen_Kreisgrenze" + ] + }, + { + "schluessel": "grenze_klasse_grenze_des_regierungsbezirks", + "bezeichnung": "Grenze des Regierungsbezirks", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "Grenzen_Regierungsbezirk" + ], + "symbol_stile": [ + "Grenzen_Regierungsbezirk" + ] + }, + { + "schluessel": "grenze_klasse_grenze_des_bundeslandes", + "bezeichnung": "Landesgrenze", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "Grenzen_Landesgrenze" + ], + "symbol_stile": [ + "Grenzen_Landesgrenze" + ] + }, + { + "schluessel": "grenze_klasse_grenze_der_bundesrepublik_deutschland", + "bezeichnung": "Staatsgrenze", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "Grenzen_Staatsgrenze" + ], + "symbol_stile": [ + "Grenzen_Staatsgrenze" + ] + }, + { + "schluessel": "grenze_grenzef_biosphaerenreservat", + "bezeichnung": "Biosphärenreservat", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeF_Biosphaerenreservat_F", + "GrenzeF_Biosphaerenreservat_aussen" + ], + "symbol_stile": [ + "GrenzeF_Biosphaerenreservat_F" + ] + }, + { + "schluessel": "grenze_grenzef_kernzone_ruhezone", + "bezeichnung": "Kernzone (Biosphärenreservat, Nationalpark)", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeF_Kernzone_Ruhezone" + ], + "symbol_stile": [ + "GrenzeF_Kernzone_Ruhezone" + ] + }, + { + "schluessel": "grenze_klasse_nationalpark", + "bezeichnung": "Nationalpark", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeF_Nationalpark_F", + "GrenzeF_Nationalpark_Kontur" + ], + "symbol_stile": [ + "GrenzeF_Nationalpark_F", + "GrenzeF_Nationalpark_Kontur" + ] + }, + { + "schluessel": "grenze_klasse_truppenuebungsplatz_standortuebungsplatz", + "bezeichnung": "Truppenübungsplatz, Standortübungsplatz", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeF_Truppen-_und_Standortuebungsplatz" + ], + "symbol_stile": [ + "GrenzeF_Truppen-_und_Standortuebungsplatz" + ] + }, + { + "schluessel": "grenze_klasse_naturschutzgebiet", + "bezeichnung": "Naturschutzgebiet", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeF_Naturschutzgebiet" + ], + "symbol_stile": [ + "GrenzeF_Naturschutzgebiet" + ] + }, + { + "schluessel": "grenze_grenzef_schutzgebiete", + "bezeichnung": "Schutzgebiet (LSG, FFH-Gebiet u. a.)", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeF_Schutzgebiete" + ], + "symbol_stile": [ + "GrenzeF_Schutzgebiete" + ] + }, + { + "schluessel": "grenze_klasse_naturdenkmal", + "bezeichnung": "Naturdenkmal", + "gruppe": "Grenzen und Schutzgebiete", + "treffer_stile": [ + "GrenzeL_Naturdenkmal" + ], + "symbol_stile": [ + "GrenzeL_Naturdenkmal" + ] + }, + { + "schluessel": "historisch_historischf_wall_graben", + "bezeichnung": "Wall, Graben (historisch)", + "gruppe": "Historisches", + "treffer_stile": [ + "HistorischF_Wall_Graben", + "HistorischL_Wall_Graben" + ], + "symbol_stile": [ + "HistorischF_Wall_Graben" + ] + }, + { + "schluessel": "historisch_historischf_lager_sonstiges", + "bezeichnung": "Befestigung, Lager (historisch)", + "gruppe": "Historisches", + "treffer_stile": [ + "HistorischF_Lager_Sonstiges" + ], + "symbol_stile": [ + "HistorischF_Lager_Sonstiges" + ] + }, + { + "schluessel": "historisch_klasse_aquaedukt_historische_wasserleitung", + "bezeichnung": "Aquädukt / Historische Wasserleitung", + "gruppe": "Historisches", + "treffer_stile": [ + "HistorischL_Wasserleitung_Linie", + "HistorischL_Wasserleitung_Punkte" + ], + "symbol_stile": [ + "HistorischL_Wasserleitung_Linie", + "HistorischL_Wasserleitung_Punkte" + ] + }, + { + "schluessel": "historisch_klasse_historische_mauer", + "bezeichnung": "Historische Mauer", + "gruppe": "Historisches", + "treffer_stile": [ + "HistorischL_Mauer_Kontur", + "HistorischL_Mauer_Decker" + ], + "symbol_stile": [ + "HistorischL_Mauer_Kontur", + "HistorischL_Mauer_Decker" + ] + }, + { + "schluessel": "historisch_historischl_sonstiges", + "bezeichnung": "Historische Anlage (Linie)", + "gruppe": "Historisches", + "treffer_stile": [ + "HistorischL_Sonstiges" + ], + "symbol_stile": [ + "HistorischL_Sonstiges" + ] + }, + { + "schluessel": "historisch_historischp_grabhuegel", + "bezeichnung": "Grabhügel", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Grabhuegel" + ], + "symbol_stile": [ + "Symbol_HistorischP_Grabhuegel" + ] + }, + { + "schluessel": "historisch_klasse_grosssteingrab_dolmen_huenenbett_steinmal", + "bezeichnung": "Großsteingrab (Dolmen, Hünenbett) / Steinmal", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Grab_Grosssteingrab_Steinmal" + ], + "symbol_stile": [ + "Symbol_HistorischP_Grab_Grosssteingrab_Steinmal" + ] + }, + { + "schluessel": "historisch_klasse_befestigung_burgruine_lager_sonstiges", + "bezeichnung": "Burgruine, Befestigung (Punkt)", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Sonstiges" + ], + "symbol_stile": [ + "Symbol_HistorischP_Sonstiges" + ] + }, + { + "schluessel": "historisch_klasse_wachturm_roemisch_warte", + "bezeichnung": "Wachturm (römisch), Warte", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Roem_Wachturm" + ], + "symbol_stile": [ + "Symbol_HistorischP_Roem_Wachturm" + ] + }, + { + "schluessel": "historisch_klasse_meilenstein_historischer_grenzstein", + "bezeichnung": "Meilenstein, historischer Grenzstein", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Meilen_Grenzstein" + ], + "symbol_stile": [ + "Symbol_HistorischP_Meilen_Grenzstein" + ] + }, + { + "schluessel": "historisch_klasse_bildstock_wegekreuz_gipfelkreuz", + "bezeichnung": "Bildstock, Wegekreuz, Gipfelkreuz", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Bildstock" + ], + "symbol_stile": [ + "Symbol_HistorischP_Bildstock" + ] + }, + { + "schluessel": "historisch_klasse_gedenkstaette_denkmal_denkstein_standbild", + "bezeichnung": "Denkmal, Gedenkstätte", + "gruppe": "Historisches", + "treffer_stile": [ + "Symbol_HistorischP_Denkmal", + "Symbol_HistorischF_Denkmal" + ], + "symbol_stile": [ + "Symbol_HistorischP_Denkmal" + ] + }, + { + "schluessel": "versorgung_versorgungsl_transportanlage_foerderband_rohrleitung", + "bezeichnung": "Förderband, Rohrleitung", + "gruppe": "Versorgung", + "treffer_stile": [ + "VersorgungsL_Transportanlage_Foerderband_Rohrleitung_Linie", + "VersorgungsL_Transportanlage_Foerderband_Rohrleitung_Kreise" + ], + "symbol_stile": [ + "VersorgungsL_Transportanlage_Foerderband_Rohrleitung_Linie", + "VersorgungsL_Transportanlage_Foerderband_Rohrleitung_Kreise" + ] + }, + { + "schluessel": "versorgung_klasse_freileitung", + "bezeichnung": "Freileitung", + "gruppe": "Versorgung", + "treffer_stile": [ + "Freileitung" + ], + "symbol_stile": [ + "Freileitung" + ] + } + ] +} diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py index 47ea050..4a0700f 100644 --- a/functions/dialog_wrapper.py +++ b/functions/dialog_wrapper.py @@ -409,3 +409,113 @@ def create_progress_dialog(total: int, title: str = "Fortschritt", label: str = """von Test abgesehen, weil vernachlässigbar; HD 18.09.2026""" return ProgressDialog(total, title, label) + + +def ask_mehrfachauswahl( + titel: str, + hinweis: str, + eintraege: list[dict], + vorauswahl: set[str] | list[str], + parent: Any = None, +) -> Optional[list[str]]: + """Zeigt eine Liste mit Häkchen, in der Einträge an- und abgewählt werden. + + Parameters + ---------- + eintraege : + Dicts mit ``schluessel`` und ``text``, optional ``gruppe`` (Zwischenüberschrift; Einträge + einer Gruppe müssen aufeinander folgen) und ``icon`` (z. B. Symbolvorschau). + vorauswahl : + Schlüssel der anfangs angehakten Einträge. + + Returns + ------- + list[str] | None + Schlüssel der angehakten Einträge in Listenreihenfolge, ``None`` bei Abbruch. + Im Mock-Modus die Vorauswahl. + """ + vorauswahl = set(vorauswahl) + if QT_VERSION == 0: # Mock-Modus + return [e["schluessel"] for e in eintraege if e["schluessel"] in vorauswahl] + + from sn_basis.functions.qt_wrapper import ( + QDialog, QDialogButtonBox, QFont, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, + QVBoxLayout, DialogAccepted, ItemIsEnabled, ItemIsUserCheckable, CheckStateChecked, + CheckStateUnchecked, UserRole, + ) + + dialog = QDialog(parent) + dialog.setWindowTitle(titel) + layout = QVBoxLayout(dialog) + + beschriftung = QLabel(hinweis) + beschriftung.setWordWrap(True) + layout.addWidget(beschriftung) + + suche = QLineEdit() + suche.setPlaceholderText("Suche …") + layout.addWidget(suche) + + liste = QListWidget() + kopf_schrift = QFont() + kopf_schrift.setBold(True) + zeilen: list[tuple[Any, dict]] = [] + koepfe: dict[str, Any] = {} + letzte_gruppe = None + for eintrag in eintraege: + gruppe = eintrag.get("gruppe") + if gruppe and gruppe != letzte_gruppe: + kopf = QListWidgetItem(gruppe) + kopf.setFlags(ItemIsEnabled) + kopf.setFont(kopf_schrift) + liste.addItem(kopf) + koepfe[gruppe] = kopf + letzte_gruppe = gruppe + zeile = QListWidgetItem(eintrag["text"]) + if eintrag.get("icon") is not None: + zeile.setIcon(eintrag["icon"]) + zeile.setFlags(ItemIsUserCheckable | ItemIsEnabled) + zeile.setCheckState(CheckStateChecked if eintrag["schluessel"] in vorauswahl else CheckStateUnchecked) + zeile.setData(UserRole, eintrag["schluessel"]) + liste.addItem(zeile) + zeilen.append((zeile, eintrag)) + layout.addWidget(liste, 1) + + def _filtern(text: str) -> None: + suchtext = text.strip().lower() + sichtbare_gruppen = set() + for zeile, eintrag in zeilen: + treffer = not suchtext or suchtext in eintrag["text"].lower() \ + or suchtext in (eintrag.get("gruppe") or "").lower() + zeile.setHidden(not treffer) + if treffer: + sichtbare_gruppen.add(eintrag.get("gruppe")) + for gruppe, kopf in koepfe.items(): + kopf.setHidden(gruppe not in sichtbare_gruppen) + + def _alle_setzen(zustand: Any) -> None: + for zeile, _ in zeilen: + if not zeile.isHidden(): + zeile.setCheckState(zustand) + + suche.textChanged.connect(_filtern) + + knopfzeile = QHBoxLayout() + alle = QPushButton("Alle auswählen") + alle.clicked.connect(lambda *_: _alle_setzen(CheckStateChecked)) + keine = QPushButton("Keine auswählen") + keine.clicked.connect(lambda *_: _alle_setzen(CheckStateUnchecked)) + knopfzeile.addWidget(alle) + knopfzeile.addWidget(keine) + knopfzeile.addStretch(1) + layout.addLayout(knopfzeile) + + knoepfe = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + knoepfe.accepted.connect(dialog.accept) + knoepfe.rejected.connect(dialog.reject) + layout.addWidget(knoepfe) + dialog.resize(460, 560) + + if exec_dialog(dialog) != DialogAccepted: + return None + return [eintrag["schluessel"] for zeile, eintrag in zeilen if zeile.checkState() == CheckStateChecked] diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py index 03a8423..e09560d 100644 --- a/functions/qgiscore_wrapper.py +++ b/functions/qgiscore_wrapper.py @@ -872,6 +872,261 @@ except Exception: QgsJsonExporter = _MockQgsJsonExporter +# --------------------------------------------------------- +# Vektorkacheln, Ausdrücke, Symbole und Legende (Basemap-Legende, Basis #88) +# Eigener Block, damit ein fehlendes Symbol den übrigen Wrapper nicht in den Mock-Modus zwingt. +# --------------------------------------------------------- + +QgsVectorTileLayer: Type[Any] +QgsSelectionContext: Type[Any] +QgsTileXYZ: Type[Any] +QgsExpression: Type[Any] +QgsExpressionContext: Type[Any] +QgsExpressionContextScope: Type[Any] +QgsProperty: Type[Any] +QgsCategorizedSymbolRenderer: Type[Any] +QgsRendererCategory: Type[Any] +QgsLegendRenderer: Type[Any] +QgsLegendStyle: Type[Any] +QgsMapLayer: Type[Any] +QgsApplication: Type[Any] +QgsSymbolLayerUtils: Type[Any] +QgsLayoutMeasurement: Type[Any] +QgsLayoutUtils: Type[Any] +SELECT_BEHAVIOR_SET: Any = None +SELECT_RELATION_INTERSECT: Any = None +PROPERTY_TYPE_EXPRESSION: Any = None +LEGEND_STYLE_HIDDEN: Any = None +LEGEND_STYLE_SUBGROUP: Any = None +STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG: Any = None +VECTOR_TILES_AVAILABLE = False + +try: + from qgis.core import ( + QgsVectorTileLayer as _QgsVectorTileLayer, + QgsSelectionContext as _QgsSelectionContext, + QgsTileXYZ as _QgsTileXYZ, + QgsExpression as _QgsExpression, + QgsExpressionContext as _QgsExpressionContext, + QgsExpressionContextScope as _QgsExpressionContextScope, + QgsProperty as _QgsProperty, + QgsCategorizedSymbolRenderer as _QgsCategorizedSymbolRenderer, + QgsRendererCategory as _QgsRendererCategory, + QgsLegendRenderer as _QgsLegendRenderer, + QgsLegendStyle as _QgsLegendStyle, + QgsMapLayer as _QgsMapLayer, + QgsApplication as _QgsApplication, + QgsSymbolLayerUtils as _QgsSymbolLayerUtils, + QgsLayoutMeasurement as _QgsLayoutMeasurement, + QgsLayoutUtils as _QgsLayoutUtils, + Qgis as _QgisVT, + ) + + QgsVectorTileLayer = _QgsVectorTileLayer + QgsSelectionContext = _QgsSelectionContext + QgsTileXYZ = _QgsTileXYZ + QgsExpression = _QgsExpression + QgsExpressionContext = _QgsExpressionContext + QgsExpressionContextScope = _QgsExpressionContextScope + QgsProperty = _QgsProperty + QgsCategorizedSymbolRenderer = _QgsCategorizedSymbolRenderer + QgsRendererCategory = _QgsRendererCategory + QgsLegendRenderer = _QgsLegendRenderer + QgsLegendStyle = _QgsLegendStyle + QgsMapLayer = _QgsMapLayer + QgsApplication = _QgsApplication + QgsSymbolLayerUtils = _QgsSymbolLayerUtils + QgsLayoutMeasurement = _QgsLayoutMeasurement + QgsLayoutUtils = _QgsLayoutUtils + + SELECT_BEHAVIOR_SET = _QgisVT.SelectBehavior.SetSelection + SELECT_RELATION_INTERSECT = _QgisVT.SelectGeometryRelationship.Intersect + PROPERTY_TYPE_EXPRESSION = getattr(getattr(_QgsProperty, "Type", None), "ExpressionBasedProperty", None) + if PROPERTY_TYPE_EXPRESSION is None: + PROPERTY_TYPE_EXPRESSION = getattr(_QgsProperty, "ExpressionBasedProperty", None) + LEGEND_STYLE_HIDDEN = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Hidden", None) + LEGEND_STYLE_SUBGROUP = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Subgroup", None) + _kategorie = getattr(_QgsMapLayer, "StyleCategory", _QgsMapLayer) + STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = _kategorie.Symbology | _kategorie.Labeling + VECTOR_TILES_AVAILABLE = True + +except Exception: + VECTOR_TILES_AVAILABLE = False + + class _MockQgsVectorTileLayer: + """Platzhalter: Vektorkachel-Layer gibt es im Mock-Modus nicht.""" + + class _MockQgsSelectionContext: + def __init__(self): + self._scale = 0.0 + + def setScale(self, scale: float) -> None: + self._scale = scale + + def scale(self) -> float: + return self._scale + + class _MockQgsTileXYZ: + def __init__(self, column: int = 0, row: int = 0, zoom: int = 0): + self._column, self._row, self._zoom = column, row, zoom + + def column(self) -> int: + return self._column + + def row(self) -> int: + return self._row + + def zoomLevel(self) -> int: + return self._zoom + + class _MockQgsExpression: + def __init__(self, text: str = ""): + self._text = text + + def prepare(self, context: Any) -> bool: + return True + + def evaluate(self, context: Any = None) -> Any: + return True + + class _MockQgsExpressionContextScope: + def __init__(self, name: str = ""): + self._variables: dict[str, Any] = {} + + def setVariable(self, name: str, value: Any, *args: Any) -> None: + self._variables[name] = value + + def variable(self, name: str) -> Any: + return self._variables.get(name) + + class _MockQgsExpressionContext: + def __init__(self): + self._scopes: list[Any] = [] + self._feature: Any = None + + def appendScope(self, scope: Any) -> None: + self._scopes.append(scope) + + def setFeature(self, feature: Any) -> None: + self._feature = feature + + class _MockQgsProperty: + def __init__(self, value: Any = None): + self._value = value + + @staticmethod + def fromValue(value: Any) -> "_MockQgsProperty": + return _MockQgsProperty(value) + + class _MockQgsRendererCategory: + def __init__(self, value: Any = None, symbol: Any = None, label: str = ""): + self._value, self._symbol, self._label = value, symbol, label + + def value(self) -> Any: + return self._value + + def symbol(self) -> Any: + return self._symbol + + def label(self) -> str: + return self._label + + class _MockQgsCategorizedSymbolRenderer: + def __init__(self, attribute: str = "", categories: list | None = None): + self._attribute = attribute + self._categories = list(categories or []) + + def categories(self) -> list: + return list(self._categories) + + class _MockQgsLegendStyle: + Hidden = 0 + Group = 1 + Subgroup = 2 + + class _MockQgsLegendRenderer: + @staticmethod + def setNodeLegendStyle(node: Any, style: Any) -> None: + setattr(node, "_legend_style", style) + + class _MockQgsMapLayer: + class StyleCategory: + Symbology = 1 + Labeling = 2 + + class _MockQgsApplication: + @staticmethod + def qgisSettingsDirPath() -> str: + return "" + + class _MockQgsSymbolLayerUtils: + @staticmethod + def symbolPreviewIcon(symbol: Any, size: Any, *args: Any) -> Any: + return None + + QgsVectorTileLayer = _MockQgsVectorTileLayer + QgsSelectionContext = _MockQgsSelectionContext + QgsTileXYZ = _MockQgsTileXYZ + QgsExpression = _MockQgsExpression + QgsExpressionContext = _MockQgsExpressionContext + QgsExpressionContextScope = _MockQgsExpressionContextScope + QgsProperty = _MockQgsProperty + QgsCategorizedSymbolRenderer = _MockQgsCategorizedSymbolRenderer + QgsRendererCategory = _MockQgsRendererCategory + QgsLegendRenderer = _MockQgsLegendRenderer + QgsLegendStyle = _MockQgsLegendStyle + QgsMapLayer = _MockQgsMapLayer + QgsApplication = _MockQgsApplication + QgsSymbolLayerUtils = _MockQgsSymbolLayerUtils + + class _MockQgsLayoutMeasurement: + def __init__(self, length: float = 0.0, units: Any = None): + self._length = length + + def length(self) -> float: + return self._length + + QgsLayoutMeasurement = _MockQgsLayoutMeasurement + + class _MockQgsLayoutUtils: + @staticmethod + def createRenderContextForLayout(layout: Any, painter: Any, dpi: float = -1) -> Any: + return None + + QgsLayoutUtils = _MockQgsLayoutUtils + + # QgsRectangle hatte im allgemeinen Mock-Block keinen Ersatz (nur Typannotation). + if "QgsRectangle" not in globals(): + class _MockQgsRectangle: + def __init__(self, x_min: float = 0.0, y_min: float = 0.0, x_max: float = 0.0, y_max: float = 0.0): + self._werte = (x_min, y_min, x_max, y_max) + + def xMinimum(self) -> float: + return self._werte[0] + + def yMinimum(self) -> float: + return self._werte[1] + + def xMaximum(self) -> float: + return self._werte[2] + + def yMaximum(self) -> float: + return self._werte[3] + + def width(self) -> float: + return self._werte[2] - self._werte[0] + + def height(self) -> float: + return self._werte[3] - self._werte[1] + + QgsRectangle = _MockQgsRectangle + SELECT_BEHAVIOR_SET = 0 + SELECT_RELATION_INTERSECT = 0 + PROPERTY_TYPE_EXPRESSION = 2 + LEGEND_STYLE_HIDDEN = _MockQgsLegendStyle.Hidden + LEGEND_STYLE_SUBGROUP = _MockQgsLegendStyle.Subgroup + STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = 3 + # --------------------------------------------------------- # Netzwerk # --------------------------------------------------------- diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index c103004..ce17f35 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -55,6 +55,7 @@ QTableWidget: Type[Any] = object QTableWidgetItem: Type[Any] = object QTableWidgetSelectionRange: Type[Any] = object QIcon: Type[Any] = object +QSize: Type[Any] = object def exec_dialog(dialog: Any) -> Any: @@ -125,6 +126,7 @@ try: Qt as _Qt, QVariant as _QVariant, QSettings as _QSettings, + QSize as _QSize, ) from qgis.PyQt.QtNetwork import ( QNetworkRequest as _QNetworkRequest, @@ -175,6 +177,7 @@ try: QTableWidgetItem = _QTableWidgetItem QTableWidgetSelectionRange = _QTableWidgetSelectionRange QIcon = _QIcon + QSize = _QSize # ✅ QT6 ENUMS YES = QMessageBox.StandardButton.Yes NO = QMessageBox.StandardButton.No @@ -200,6 +203,11 @@ try: SelectRows = QAbstractItemView.SelectionBehavior.SelectRows NoEditTriggers = QAbstractItemView.EditTrigger.NoEditTriggers AscendingOrder = Qt.SortOrder.AscendingOrder + ItemIsUserCheckable = Qt.ItemFlag.ItemIsUserCheckable + ItemIsEnabled = Qt.ItemFlag.ItemIsEnabled + CheckStateChecked = Qt.CheckState.Checked + CheckStateUnchecked = Qt.CheckState.Unchecked + UserRole = Qt.ItemDataRole.UserRole def exec_dialog(dialog: Any) -> Any: return dialog.exec() @@ -249,6 +257,7 @@ except (ImportError, AttributeError): Qt as _Qt, QVariant as _QVariant, QSettings as _QSettings, + QSize as _QSize, ) from PyQt5.QtNetwork import ( QNetworkRequest as _QNetworkRequest, @@ -297,6 +306,7 @@ except (ImportError, AttributeError): QTableWidgetItem = _QTableWidgetItem QTableWidgetSelectionRange = _QTableWidgetSelectionRange QIcon = _QIcon + QSize = _QSize # ✅ PYQT5 ENUMS YES = QMessageBox.Yes @@ -324,6 +334,11 @@ except (ImportError, AttributeError): SelectRows = QAbstractItemView.SelectRows NoEditTriggers = QAbstractItemView.NoEditTriggers AscendingOrder = Qt.AscendingOrder + ItemIsUserCheckable = Qt.ItemIsUserCheckable + ItemIsEnabled = Qt.ItemIsEnabled + CheckStateChecked = Qt.Checked + CheckStateUnchecked = Qt.Unchecked + UserRole = Qt.UserRole def exec_dialog(dialog: Any) -> Any: return dialog.exec_() @@ -687,6 +702,19 @@ except (ImportError, AttributeError): SelectRows = 1 NoEditTriggers = 0 AscendingOrder = 0 + ItemIsUserCheckable = 16 + ItemIsEnabled = 32 + CheckStateChecked = 2 + CheckStateUnchecked = 0 + UserRole = 256 + + class _MockQSize: + def __init__(self, width: int = 0, height: int = 0): + self._width, self._height = width, height + def width(self) -> int: return self._width + def height(self) -> int: return self._height + + QSize = _MockQSize class _MockQDialog(_MockWidget): DialogCode = type("DialogCode", (), {"Accepted": DialogAccepted, "Rejected": 0}) diff --git a/modules/basemap_kacheln.py b/modules/basemap_kacheln.py new file mode 100644 index 0000000..329ba66 --- /dev/null +++ b/modules/basemap_kacheln.py @@ -0,0 +1,192 @@ +""" +sn_basis/modules/basemap_kacheln.py – Kacheln des Basemap-Layers auswerten + +Liest die Renderer-Styles eines Vektorkachel-Layers, rechnet Maßstab in Zoomstufen um wie der +QGIS-Renderer und sammelt die Objekte eines Auswertebereichs kachelweise über +``QgsVectorTileLayer.selectByGeometry``. Teil der Basemap-Legende (``basemap_legende``). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from sn_basis.functions.qgiscore_wrapper import ( + QgsExpression, + QgsExpressionContext, + QgsExpressionContextScope, + QgsGeometry, + QgsRectangle, + QgsSelectionContext, + QgsTileXYZ, + SELECT_BEHAVIOR_SET, + SELECT_RELATION_INTERSECT, +) + +_KACHELFELDER = ("tile_zoom", "tile_layer") + + +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 + + +@dataclass(frozen=True) +class StilInfo: + """Renderer-Style des Basemap-Layers in QGIS-unabhängiger Form.""" + + name: str + layer_name: str + geometrie: int # 0 Punkt, 1 Linie, 2 Fläche + min_zoom: int = -1 + max_zoom: int = -1 + aktiv: bool = True + filter: str = "" + + +@dataclass +class ObjektInfo: + """Ein (entdoppeltes) Objekt aus den Kacheln des Auswertebereichs.""" + + layer_name: str + geometrie: int + attribute: dict + merkmal: Any = None # QgsFeature für die Auswertung der Filterausdrücke in QGIS + + +def _enum_int(wert: Any) -> int: + try: + return int(wert) + except (TypeError, ValueError): + return int(getattr(wert, "value", -1)) + + +def stile_aus_layer(layer: Any) -> list[StilInfo]: + """Liest die Renderer-Styles eines Vektorkachel-Layers.""" + renderer = _aufruf(layer, "renderer") + stile = _aufruf(renderer, "styles", []) or [] + return [ + StilInfo( + name=s.styleName(), + layer_name=s.layerName(), + geometrie=_enum_int(s.geometryType()), + min_zoom=s.minZoomLevel(), + max_zoom=s.maxZoomLevel(), + aktiv=s.isEnabled(), + filter=s.filterExpression(), + ) + for s in stile + ] + + +def zoomstufen(layer: Any, massstab: float) -> tuple[int, int, float]: + """Kachel-, Darstellungs- und gebrochene Zoomstufe wie im QGIS-Vektorkachel-Renderer. + + ``zoom_level`` = ``scaleToZoomLevel(maßstab, False)`` (nicht auf die Kachelstufen begrenzt), + ``vector_tile_zoom`` = ``scaleToZoom(maßstab)``. Für Karten in EPSG:25833 entspricht der + Kachelmaßstab dem Kartenmaßstab (geprüft mit ``calculateTileScaleForMap``, QGIS 3.44). + """ + matrix = layer.tileMatrixSet() + return ( + matrix.scaleToZoomLevel(massstab), + matrix.scaleToZoomLevel(massstab, False), + matrix.scaleToZoom(massstab), + ) + + +def qgis_filterpruefer(vector_tile_zoom: float, zoom_level: int) -> Callable[[str, ObjektInfo], bool]: + """Liefert eine Filterprüfung, die QGIS-Ausdrücke wie der Renderer auswertet.""" + kontext = QgsExpressionContext() + scope = QgsExpressionContextScope() + scope.setVariable("vector_tile_zoom", vector_tile_zoom) + scope.setVariable("zoom_level", zoom_level) + kontext.appendScope(scope) + ausdruecke: dict[str, Any] = {} + + def pruefe(filter_text: str, objekt: ObjektInfo) -> bool: + ausdruck = ausdruecke.get(filter_text) + if ausdruck is None: + ausdruck = QgsExpression(filter_text) + ausdruck.prepare(kontext) + ausdruecke[filter_text] = ausdruck + kontext.setFeature(objekt.merkmal) + return bool(ausdruck.evaluate(kontext)) + + return pruefe + + +def _objekt_schluessel(merkmal: Any) -> tuple: + werte = [] + for feld in merkmal.fields(): + name = feld.name() + if name not in _KACHELFELDER: + werte.append((name, str(merkmal[name]))) + return merkmal["tile_layer"], _enum_int(merkmal.geometry().type()), tuple(sorted(werte)) + + +def sammle_objekte( + layer: Any, + geometrie: Any, + massstab: float, + *, + fortschritt: Callable[[int, int], bool] | None = None, +) -> list[ObjektInfo] | None: + """Wertet die Kacheln des Basemap-Layers im Auswertebereich aus. + + ``geometrie`` liegt im KBS des Layers (EPSG:3857). Ausgewertet wird kachelweise über + ``selectByGeometry``, damit Fortschritt und Abbruch möglich sind. Gleiche Objekte (gleicher + Quell-Layer, Geometrietyp und Attribute) werden entdoppelt. Eine vorhandene Auswahl auf dem + Basemap-Layer wird dabei aufgehoben. + + Returns + ------- + list[ObjektInfo] | None + ``None``, wenn der Nutzer abgebrochen hat. + """ + matrix_set = layer.tileMatrixSet() + zoom = matrix_set.scaleToZoomLevel(massstab) + matrix = matrix_set.tileMatrix(zoom) + bereich = matrix.tileRangeFromExtent(geometrie.boundingBox()) + kacheln = [ + (spalte, zeile) + for spalte in range(bereich.startColumn(), bereich.endColumn() + 1) + for zeile in range(bereich.startRow(), bereich.endRow() + 1) + ] + objekte: dict[tuple, ObjektInfo] = {} + try: + for nummer, (spalte, zeile) in enumerate(kacheln, start=1): + if fortschritt is not None and not fortschritt(nummer, len(kacheln)): + return None + rechteck = matrix.tileExtent(QgsTileXYZ(spalte, zeile, zoom)) + rand = rechteck.width() * 1e-6 + innen = QgsRectangle(rechteck.xMinimum() + rand, rechteck.yMinimum() + rand, + rechteck.xMaximum() - rand, rechteck.yMaximum() - rand) + teil = geometrie.intersection(QgsGeometry.fromRect(innen)) + if teil is None or teil.isEmpty(): + continue + kontext = QgsSelectionContext() + kontext.setScale(massstab) + layer.selectByGeometry(teil, kontext, SELECT_BEHAVIOR_SET, SELECT_RELATION_INTERSECT) + for merkmal in layer.selectedFeatures(): + schluessel = _objekt_schluessel(merkmal) + if schluessel not in objekte: + objekte[schluessel] = ObjektInfo( + layer_name=schluessel[0], + geometrie=schluessel[1], + attribute=dict(schluessel[2]), + merkmal=merkmal, + ) + finally: + _aufruf(layer, "removeSelection") + return list(objekte.values()) + + +def anzahl_kacheln(layer: Any, geometrie: Any, massstab: float) -> int: + matrix_set = layer.tileMatrixSet() + zoom = matrix_set.scaleToZoomLevel(massstab) + bereich = matrix_set.tileMatrix(zoom).tileRangeFromExtent(geometrie.boundingBox()) + return max(0, bereich.endColumn() - bereich.startColumn() + 1) * max(0, bereich.endRow() - bereich.startRow() + 1) diff --git a/modules/basemap_katalog.py b/modules/basemap_katalog.py new file mode 100644 index 0000000..d2fc9d4 --- /dev/null +++ b/modules/basemap_katalog.py @@ -0,0 +1,418 @@ +""" +sn_basis/modules/basemap_katalog.py – Legendenkatalog für basemap.de erzeugen + +Erzeugt aus der Style-JSON von basemap.de (``bm_web_col.json``) den Legendenkatalog +``data/basemap_legende.json``. Der Katalog ordnet jede Style-ID genau einem +Legendeneintrag zu (z. B. gehören ``Kontur_Kreisstr``, ``Decker_Kreisstr`` und +``Bruecke_Kreisstr_Decker`` zum Eintrag „Kreisstraße“) und legt fest, welche +Styles zusammen das Legendensymbol bilden. + +Das Modul nutzt nur die Standardbibliothek und lässt sich auch eigenständig +aufrufen, um den Katalog nach einer neuen Basemap-Stilversion zu erneuern:: + + python modules/basemap_katalog.py # lädt die Style-JSON und schreibt data/basemap_legende.json + python modules/basemap_katalog.py --stil datei.json + +Bestehende fachliche Nachbearbeitung (Bezeichnungen, Gruppen, Reihenfolge, +Zuordnung einzelner Style-IDs) bleibt bei einem erneuten Lauf erhalten. +""" +from __future__ import annotations + +import json +import os +import re +import sys +from typing import Any + +STIL_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/styles/bm_web_col.json" + +# Style-IDs, die nie in die Legende gehören (Flächenfüllung des Kartenhintergrunds). +NIE_IN_LEGENDE = ["Hintergrund"] + +# Präfixe von Style-IDs, die nie in die Legende gehören: Beschriftungen und Straßennummernschilder +# (``Nummer_*`` kommen als Symbol mit Schildhintergrund im Renderer an). +NIE_IN_LEGENDE_PRAEFIXE = ["Name_", "Nummer_"] + +# Bedingungen, die einen eigenen Legendeneintrag begründen. Alle anderen Filterbedingungen +# (bauwerk, fahrbahn, anzahl, fahrbahnachse …) sind Darstellungsvarianten desselben Eintrags. +BEDEUTUNGSTRAGENDE_ATTRIBUTE = ("klasse", "zustand", "merkmal", "zone", "art") + +# Teile einer Style-ID, die nur eine Darstellungsvariante kennzeichnen. +VARIANTEN_KENNUNGEN = ( + "Bruecke", "Bruecken", "Tunnel", "Staumauer", "Staudamm", "getrennt", "getr", "Fahrbahnachse", + "FSZ1", "einblenden", "Kontur", "Decker", "Fuellung", "ohne", "Linie", "Punkte", "Kreise", "Enden", + "Versatz", "Symbol", "L", "F", "P", +) + +# Rollen von Style-Layern, die zusammen ein Legendensymbol bilden (je Rolle ein Style). +SYMBOL_ROLLEN = ("Kontur", "Decker", "Fuellung", "Linie", "Punkte", "Kreise", "Enden", "Versatz") + +# Objektfamilie je Quell-Layer: Fläche, Linie und Punkt derselben Objektart bilden einen Eintrag. +_FAMILIE_JE_QUELL_LAYER = { + "Bauwerksflaeche": "Bauwerk", "Bauwerkslinie": "Bauwerk", "Bauwerkspunkt": "Bauwerk", + "Gewaesserflaeche": "Gewaesser", "Gewaesserlinie": "Gewaesser", "Gewaesserpunkt": "Gewaesser", + "Reliefflaeche": "Relief", "Relieflinie": "Relief", "Reliefpunkt": "Relief", + "Historische_Flaeche": "Historisch", "Historische_Linie": "Historisch", "Historischer_Punkt": "Historisch", + "Besondere_Flaeche": "Besonders", "Besondere_Linie": "Besonders", "Besonderer_Punkt": "Besonders", + "Vegetationsflaeche": "Vegetation", "Vegetationslinie": "Vegetation", "Vegetationspunkt": "Vegetation", + "Verkehrsflaeche": "Verkehr", "Verkehrslinie": "Verkehr", "Verkehrspunkt": "Verkehr", + "Gebaeudeflaeche": "Gebaeude", "Gebaeudepunkt": "Gebaeude", + "Grenze_Flaeche": "Grenze", "Grenze_Linie": "Grenze", "Grenze_Punkt": "Grenze", + "Siedlungsflaeche": "Siedlung", "Weitere_Nutzung_Flaeche": "Siedlung", + "Versorgungslinie": "Versorgung", "Versorgungspunkt": "Versorgung", +} + +GRUPPEN = [ + "Verkehr", + "Gewässer", + "Vegetation", + "Siedlung und Nutzung", + "Gebäude", + "Bauwerke", + "Relief", + "Grenzen und Schutzgebiete", + "Historisches", + "Versorgung", + "Sonstiges", +] + +_GRUPPE_JE_QUELL_LAYER = { + "Verkehrslinie": "Verkehr", + "Verkehrsflaeche": "Verkehr", + "Verkehrspunkt": "Verkehr", + "Gewaesserlinie": "Gewässer", + "Gewaesserflaeche": "Gewässer", + "Gewaesserpunkt": "Gewässer", + "Vegetationsflaeche": "Vegetation", + "Vegetationslinie": "Vegetation", + "Vegetationspunkt": "Vegetation", + "Siedlungsflaeche": "Siedlung und Nutzung", + "Weitere_Nutzung_Flaeche": "Siedlung und Nutzung", + "Gebaeudeflaeche": "Gebäude", + "Gebaeudepunkt": "Gebäude", + "Bauwerksflaeche": "Bauwerke", + "Bauwerkslinie": "Bauwerke", + "Bauwerkspunkt": "Bauwerke", + "Barrierenlinie": "Bauwerke", + "Reliefflaeche": "Relief", + "Relieflinie": "Relief", + "Reliefpunkt": "Relief", + "Grenze_Flaeche": "Grenzen und Schutzgebiete", + "Grenze_Linie": "Grenzen und Schutzgebiete", + "Grenze_Punkt": "Grenzen und Schutzgebiete", + "Historische_Flaeche": "Historisches", + "Historische_Linie": "Historisches", + "Historischer_Punkt": "Historisches", + "Besondere_Flaeche": "Historisches", + "Besondere_Linie": "Historisches", + "Besonderer_Punkt": "Historisches", + "Versorgungslinie": "Versorgung", + "Versorgungspunkt": "Versorgung", +} + +# Ersetzungen, um aus ASCII-Style-IDs lesbare deutsche Bezeichnungen zu bilden. +_UMLAUT_ERSETZUNGEN = ( + ("Strasse", "Straße"), ("Str", "Straße"), ("strasse", "straße"), ("Fuss", "Fuß"), ("fuss", "fuß"), + ("Gewaesser", "Gewässer"), ("gewaesser", "gewässer"), ("Flaeche", "Fläche"), ("flaeche", "fläche"), + ("Gruen", "Grün"), ("gruen", "grün"), ("Gehoelz", "Gehölz"), ("Bruecke", "Brücke"), + ("Uebung", "Übung"), ("uebung", "übung"), ("Foerder", "Förder"), ("foerder", "förder"), + ("Behaelter", "Behälter"), ("Schiess", "Schieß"), ("oeffentlich", "öffentlich"), + ("Daemme", "Dämme"), ("Fliess", "Fließ"), ("Geroell", "Geröll"), ("Dueker", "Düker"), + ("Gaenger", "Gänger"), ("gaenger", "gänger"), ("Uebergang", "Übergang"), ("Hoehe", "Höhe"), + ("hoehe", "höhe"), ("Schleuse", "Schleuse"), ("Faehr", "Fähr"), ("Muehle", "Mühle"), + ("Sumpf", "Sumpf"), ("Huegel", "Hügel"), ("huegel", "hügel"), ("Aussichts", "Aussichts"), + ("Schoepf", "Schöpf"), ("Gelaende", "Gelände"), ("gelaende", "gelände"), ("Oe", "Ö"), +) + +_ZUSTAND_KURZ = { + "Im Bau": "im Bau", + "Außer Betrieb, stillgelegt, verlassen": "außer Betrieb", +} + + +def lade_json(pfad: str) -> dict: + with open(pfad, encoding="utf-8") as datei: + return json.load(datei) + + +def _sammle_bedingungen(filter_ausdruck: Any, bedingungen: dict[str, set[str]]) -> None: + """Sammelt ``==``/``in``-Bedingungen eines Mapbox-Filters (rekursiv über ``all``/``any``).""" + if not isinstance(filter_ausdruck, list) or not filter_ausdruck: + return + operator = filter_ausdruck[0] + if operator in ("==", "in") and len(filter_ausdruck) >= 3 and isinstance(filter_ausdruck[1], str): + bedingungen.setdefault(filter_ausdruck[1], set()).update(str(w) for w in filter_ausdruck[2:]) + elif operator in ("all", "any"): + for teil in filter_ausdruck[1:]: + _sammle_bedingungen(teil, bedingungen) + + +def ist_renderer_stil(stil: dict) -> bool: + """Ob ein Style-Layer in QGIS als Renderer-Style (und nicht als Beschriftung) ankommt. + + Reine Beschriftungen (``symbol`` mit ``text-field`` ohne ``icon-image``) landen beim + Import in ``QgsVectorTileBasicLabeling``; ``fill-extrusion`` (3D-Gebäude) übernimmt QGIS + nicht. Beides erscheint nie in der Legende. Geprüft gegen QGIS 3.44 (02.10.2026). + """ + typ = stil.get("type") + if typ in ("fill", "line", "circle"): + return True + if typ == "symbol": + return "icon-image" in (stil.get("layout") or {}) + return False + + +def ist_ausgeschlossen(stil_id: str, nie_in_legende: list[str], praefixe: list[str]) -> bool: + """Ob eine Style-ID nie in die Legende gehört (Ausschlussliste oder Präfix).""" + return stil_id in nie_in_legende or any(stil_id.startswith(p) for p in praefixe) + + +def normalisiere_stil_id(stil_id: str) -> str: + """Entfernt Variantenkennungen (Kontur, Decker, Brücke …) aus einer Style-ID.""" + teile = [t for t in stil_id.split("_") + if t and t not in VARIANTEN_KENNUNGEN and not re.fullmatch(r"\d+m", t)] + return "_".join(teile) or stil_id + + +def _slug(text: str) -> str: + text = text.lower() + for alt, neu in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): + text = text.replace(alt, neu) + return re.sub(r"[^a-z0-9]+", "_", text).strip("_") + + +def _bezeichnung_aus_id(stil_id: str) -> str: + name = normalisiere_stil_id(stil_id) + teile = name.split("_") + # Präfixe wie "SiedlungF", "VerkehrsL", "Symbol", "BauwerkP" tragen keine Bedeutung für die Legende. + while len(teile) > 1 and (teile[0] == "Symbol" or re.fullmatch(r"[A-Z][a-z]+[FLP]", teile[0]) + or teile[0] in ("Gebaeude2D", "Gebaeudepunkt", "Verkehrsflaeche", "Gewaesser", + "IstWeitereNutzung", "Grenzen")): + teile = teile[1:] + woerter = [] + for teil in teile: + for alt, neu in _UMLAUT_ERSETZUNGEN: + if teil == alt or teil.startswith(alt): + teil = neu + teil[len(alt):] + break + woerter.append(teil) + return " ".join(woerter) + + +def _bezeichnung(stil_id: str, bedingungen: dict[str, set[str]]) -> str: + klassen = sorted(bedingungen.get("klasse", set())) + if 1 <= len(klassen) <= 2: + text = " / ".join(klassen) + else: + text = _bezeichnung_aus_id(stil_id) + if "merkmal" in bedingungen: + text += ", nicht ständig wasserführend" if any("ständig" in m for m in bedingungen["merkmal"]) else "" + zustaende = sorted(_ZUSTAND_KURZ.get(z, z) for z in bedingungen.get("zustand", set())) + if zustaende: + text += f" ({' / '.join(zustaende)})" + return text + + +def eintrags_schluessel(stil: dict) -> tuple[str, str]: + """Bildet Schlüssel und Bezeichnung des Legendeneintrags, zu dem ein Style-Layer gehört.""" + bedingungen: dict[str, set[str]] = {} + _sammle_bedingungen(stil.get("filter"), bedingungen) + quell_layer = stil.get("source-layer", "") + familie = _FAMILIE_JE_QUELL_LAYER.get(quell_layer, quell_layer) + bedeutung = {k: v for k, v in bedingungen.items() if k in BEDEUTUNGSTRAGENDE_ATTRIBUTE} + if "klasse" in bedeutung: + teile = [familie] + [f"{k}={'|'.join(sorted(bedeutung[k]))}" for k in sorted(bedeutung)] + else: + teile = [familie, normalisiere_stil_id(stil.get("id", ""))] + [ + f"{k}={'|'.join(sorted(bedeutung[k]))}" for k in sorted(bedeutung)] + schluessel = _slug("_".join(teile)) + if len(schluessel) > 60: + # Lange Klassenlisten kürzen, Eindeutigkeit über die normalisierte Style-ID. + schluessel = _slug(f"{familie}_{normalisiere_stil_id(stil.get('id', ''))}") + return schluessel, _bezeichnung(stil.get("id", ""), bedingungen) + + +def _ist_grundvariante(stil_id: str) -> bool: + """Ob eine Style-ID die Normaldarstellung (keine Brücke, kein Tunnel, keine Sonderfahrbahn) zeigt.""" + teile = set(stil_id.split("_")) + return not teile & {"Bruecke", "Bruecken", "Tunnel", "Staumauer", "getrennt", "getr", "Fahrbahnachse", + "FSZ1", "einblenden"} + + +def waehle_symbol_stile(treffer_stile: list[str]) -> list[str]: + """Wählt die Styles, die zusammen das Legendensymbol bilden. + + Aus den Grundvarianten (keine Brücke, kein Tunnel, keine Sonderfahrbahn) wird je Rolle + (Kontur, Decker, Füllung …) der erste Style genommen, z. B. ``Kontur_Kreisstr`` + + ``Decker_Kreisstr``. Ohne Grundvariante gelten alle Treffer-Styles als Ausgangsmenge. + """ + kandidaten = [s for s in treffer_stile if _ist_grundvariante(s)] or list(treffer_stile) + gewaehlt: list[str] = [] + rollen: set[str] = set() + for stil_id in kandidaten: + teile = stil_id.split("_") + rolle = next((r for r in SYMBOL_ROLLEN if r in teile), "basis") + if rolle in rollen: + continue + rollen.add(rolle) + gewaehlt.append(stil_id) + return gewaehlt + + +def erzeuge_katalog(stil_json: dict, bisher: dict | None = None) -> tuple[dict, list[str]]: + """Erzeugt den Legendenkatalog aus der Style-JSON. + + ``bisher`` ist ein bereits vorhandener (ggf. fachlich nachbearbeiteter) Katalog. Seine + Zuordnung Style-ID → Eintrag, Bezeichnungen, Gruppen und Reihenfolge bleiben erhalten; + neue Style-IDs werden ergänzt, entfallene entfernt. + + Returns + ------- + (katalog, meldungen) + """ + meldungen: list[str] = [] + bisher = bisher or {} + bisherige_eintraege = {e["schluessel"]: e for e in bisher.get("eintraege", [])} + bisherige_zuordnung = { + stil_id: e["schluessel"] for e in bisher.get("eintraege", []) for stil_id in e.get("treffer_stile", []) + } + nie = list(dict.fromkeys(list(bisher.get("nie_in_legende", [])) + NIE_IN_LEGENDE)) + praefixe = list(dict.fromkeys(list(bisher.get("nie_in_legende_praefixe", [])) + NIE_IN_LEGENDE_PRAEFIXE)) + + eintraege: dict[str, dict] = {} + reihenfolge_neu: list[str] = [] + gueltige_ids: set[str] = set() + + for stil in stil_json.get("layers", []): + stil_id = stil.get("id", "") + if not stil_id or ist_ausgeschlossen(stil_id, nie, praefixe) or not ist_renderer_stil(stil): + continue + gueltige_ids.add(stil_id) + schluessel, bezeichnung = eintrags_schluessel(stil) + schluessel = bisherige_zuordnung.get(stil_id, schluessel) + if schluessel not in eintraege: + vorlage = bisherige_eintraege.get(schluessel, {}) + eintraege[schluessel] = { + "schluessel": schluessel, + "bezeichnung": vorlage.get("bezeichnung", bezeichnung), + "gruppe": vorlage.get("gruppe", _GRUPPE_JE_QUELL_LAYER.get(stil.get("source-layer", ""), "Sonstiges")), + "treffer_stile": [], + "symbol_stile": [], + } + reihenfolge_neu.append(schluessel) + if schluessel not in bisherige_eintraege and bisher: + meldungen.append(f"Neuer Eintrag: {schluessel} ({bezeichnung})") + eintraege[schluessel]["treffer_stile"].append(stil_id) + + for eintrag in eintraege.values(): + vorlage = bisherige_eintraege.get(eintrag["schluessel"], {}) + symbol_bisher = [s for s in vorlage.get("symbol_stile", []) if s in gueltige_ids] + if symbol_bisher: + eintrag["symbol_stile"] = symbol_bisher + else: + eintrag["symbol_stile"] = waehle_symbol_stile(eintrag["treffer_stile"]) + + for stil_id, schluessel in bisherige_zuordnung.items(): + if stil_id not in gueltige_ids: + meldungen.append(f"Style-ID entfallen: {stil_id} (war {schluessel})") + + # Reihenfolge: bisherige Reihenfolge zuerst, neue Einträge in Reihenfolge der Style-JSON dahinter, + # innerhalb der Gruppenreihenfolge. + alte_reihenfolge = [e["schluessel"] for e in bisher.get("eintraege", []) if e["schluessel"] in eintraege] + neue = [s for s in reihenfolge_neu if s not in alte_reihenfolge] + gruppen = list(bisher.get("gruppen", GRUPPEN)) + for eintrag in eintraege.values(): + if eintrag["gruppe"] not in gruppen: + gruppen.append(eintrag["gruppe"]) + if alte_reihenfolge: + sortiert = alte_reihenfolge + neue + else: + sortiert = sorted(neue, key=lambda s: gruppen.index(eintraege[s]["gruppe"])) + + katalog = { + "beschreibung": ( + "Legendenkatalog für basemap.de Web Vektor. Ordnet jede Style-ID genau einem Legendeneintrag zu. " + "Erzeugt mit modules/basemap_katalog.py, danach fachlich nachbearbeitet." + ), + "quelle": { + "url": STIL_URL, + "name": stil_json.get("name", ""), + "version": stil_json.get("version", ""), + }, + "gruppen": gruppen, + "nie_in_legende": nie, + "nie_in_legende_praefixe": praefixe, + "eintraege": [eintraege[s] for s in sortiert], + } + for name in [n for n in pruefe_katalog(katalog)]: + meldungen.append(f"Katalogfehler: {name}") + return katalog, meldungen + + +def pruefe_katalog(katalog: dict) -> list[str]: + """Prüft die Konsistenz eines Katalogs und liefert eine Liste von Fehlermeldungen.""" + fehler: list[str] = [] + gesehen: dict[str, str] = {} + schluessel_gesehen: set[str] = set() + alle_stile = {s for e in katalog.get("eintraege", []) for s in e.get("treffer_stile", [])} + for eintrag in katalog.get("eintraege", []): + schluessel = eintrag.get("schluessel", "") + if not schluessel: + fehler.append("Eintrag ohne Schlüssel") + continue + if schluessel in schluessel_gesehen: + fehler.append(f"Schlüssel doppelt: {schluessel}") + schluessel_gesehen.add(schluessel) + if eintrag.get("gruppe") not in katalog.get("gruppen", []): + fehler.append(f"{schluessel}: Gruppe '{eintrag.get('gruppe')}' nicht in der Gruppenliste") + for stil_id in eintrag.get("treffer_stile", []): + if stil_id in gesehen: + fehler.append(f"Style-ID {stil_id} in {gesehen[stil_id]} und {schluessel}") + gesehen[stil_id] = schluessel + # symbol_stile dürfen auch Styles anderer Einträge nutzen (gemeinsame Kontur von + # „Straße im Bau“ und „Straße außer Betrieb“), müssen aber im Katalog vorkommen. + unbekannt = set(eintrag.get("symbol_stile", [])) - alle_stile + if unbekannt: + fehler.append(f"{schluessel}: symbol_stile nicht im Katalog: {', '.join(sorted(unbekannt))}") + if not eintrag.get("symbol_stile"): + fehler.append(f"{schluessel}: keine symbol_stile") + return fehler + + +def _standard_zielpfad() -> str: + return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "basemap_legende.json") + + +def main(argv: list[str] | None = None) -> int: + """Kommandozeile: Style-JSON laden, Katalog erzeugen bzw. aktualisieren und schreiben.""" + import argparse + import urllib.request + + parser = argparse.ArgumentParser(description="Legendenkatalog für basemap.de erzeugen oder aktualisieren.") + parser.add_argument("--stil", help="lokale Style-JSON statt Download von basemap.de") + parser.add_argument("--ziel", default=_standard_zielpfad(), help="Zieldatei (Standard: data/basemap_legende.json)") + parser.add_argument("--neu", action="store_true", help="bestehenden Katalog ignorieren") + args = parser.parse_args(argv) + + if args.stil: + stil_json = lade_json(args.stil) + else: + with urllib.request.urlopen(STIL_URL, timeout=60) as antwort: + stil_json = json.loads(antwort.read().decode("utf-8")) + + bisher = None + if not args.neu and os.path.exists(args.ziel): + bisher = lade_json(args.ziel) + + katalog, meldungen = erzeuge_katalog(stil_json, bisher) + with open(args.ziel, "w", encoding="utf-8", newline="\n") as datei: + json.dump(katalog, datei, ensure_ascii=False, indent=1) + datei.write("\n") + for meldung in meldungen: + print(meldung) + print(f"{len(katalog['eintraege'])} Einträge geschrieben: {args.ziel}") + return 1 if any(m.startswith("Katalogfehler") for m in meldungen) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modules/basemap_legende.py b/modules/basemap_legende.py new file mode 100644 index 0000000..bba5aa7 --- /dev/null +++ b/modules/basemap_legende.py @@ -0,0 +1,471 @@ +""" +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} diff --git a/modules/basemap_legende_layout.py b/modules/basemap_legende_layout.py new file mode 100644 index 0000000..52bc518 --- /dev/null +++ b/modules/basemap_legende_layout.py @@ -0,0 +1,396 @@ +""" +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 diff --git a/modules/basemap_stilvorlagen.py b/modules/basemap_stilvorlagen.py new file mode 100644 index 0000000..a24543e --- /dev/null +++ b/modules/basemap_stilvorlagen.py @@ -0,0 +1,181 @@ +""" +sn_basis/modules/basemap_stilvorlagen.py – lokale Stilvorlagen für den Basemap-Layer + +Speichert die Darstellung des Basemap-Layers (Symbologie und Beschriftung, also auch +abgeschaltete Styles wie Hausnummern) als benannte Stilvorlage im QGIS-Profil und lädt sie +wieder. Mehrere Varianten sind möglich; eine davon kann als Standard markiert werden. + +Ablage: ``/sn_basis/basemap_stile/.qml`` plus ``.json`` mit +Anzeigename, Erstellungszeit, Style-Namen der Vorlage und – optional – der Legendenauswahl. +Der Standard steht in ``QSettings`` unter ``sn_basis/basemap_standardstil``. +Die Vorlagen sind lokal (je Rechner und Profil) und werden nicht geteilt. +""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sn_basis.functions.qgiscore_wrapper import QgsApplication, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG +from sn_basis.functions.qt_wrapper import QSettings +from sn_basis.functions.text_wrapper import sanitize_bezeichner + +STANDARD_SCHLUESSEL = "sn_basis/basemap_standardstil" + + +@dataclass +class Stilvorlage: + name: str # Dateiname ohne Endung + anzeigename: str + pfad: str # QML-Datei + erstellt: str = "" + standard: bool = False + + +def standard_ordner() -> str: + """Ordner der Stilvorlagen im aktiven QGIS-Profil (außerhalb von QGIS im Benutzerordner).""" + try: + basis = QgsApplication.qgisSettingsDirPath() or "" + except Exception: + basis = "" + if not basis: + basis = os.path.join(os.path.expanduser("~"), ".sn_basis") + return os.path.join(basis, "sn_basis", "basemap_stile") + + +def _stilnamen(layer: Any) -> list[str]: + try: + return [s.styleName() for s in layer.renderer().styles()] + except Exception: + return [] + + +class BasemapStilvorlagen: + """Verwaltet die lokalen Stilvorlagen des Basemap-Layers.""" + + def __init__(self, ordner: str | None = None, einstellungen: Any | None = None) -> None: + self.ordner = ordner or standard_ordner() + self.einstellungen = einstellungen if einstellungen is not None else QSettings() + + # --- Abfragen ------------------------------------------------------- + + def _pfade(self, name: str) -> tuple[str, str]: + basis = os.path.join(self.ordner, name) + return basis + ".qml", basis + ".json" + + def _metadaten(self, name: str) -> dict: + _, json_pfad = self._pfade(name) + try: + with open(json_pfad, encoding="utf-8") as datei: + daten = json.load(datei) + return daten if isinstance(daten, dict) else {} + except (OSError, ValueError): + return {} + + @staticmethod + def dateiname(anzeigename: str) -> str: + return sanitize_bezeichner(anzeigename.strip()) + + def standard(self) -> str: + return str(self.einstellungen.value(STANDARD_SCHLUESSEL, "") or "") + + def liste(self) -> list[Stilvorlage]: + """Alle Stilvorlagen, alphabetisch nach Anzeigename. Fehlende Begleitdatei wird toleriert.""" + if not os.path.isdir(self.ordner): + return [] + standard = self.standard() + vorlagen = [] + for datei in os.listdir(self.ordner): + if not datei.lower().endswith(".qml"): + continue + name = datei[:-4] + meta = self._metadaten(name) + vorlagen.append(Stilvorlage( + name=name, + anzeigename=str(meta.get("anzeigename") or name), + pfad=os.path.join(self.ordner, datei), + erstellt=str(meta.get("erstellt", "")), + standard=(name == standard), + )) + return sorted(vorlagen, key=lambda v: v.anzeigename.lower()) + + def vorhanden(self, anzeigename: str) -> bool: + return os.path.exists(self._pfade(self.dateiname(anzeigename))[0]) + + def legendenauswahl(self, name: str) -> dict | None: + """In der Vorlage gespeicherte Legendenauswahl (``ausgeblendet``/``eingeblendet``) oder ``None``.""" + auswahl = self._metadaten(name).get("legende_auswahl") + return auswahl if isinstance(auswahl, dict) else None + + def pruefe_passung(self, layer: Any, name: str) -> list[str]: + """Style-Namen der Vorlage, die der Layer nicht kennt (z. B. Vorlage aus „Grau“ für „Farbe“).""" + vorlage = self._metadaten(name).get("stile") + if not isinstance(vorlage, list): + return [] + vorhanden = set(_stilnamen(layer)) + return [s for s in vorlage if s not in vorhanden] + + # --- Ändern --------------------------------------------------------- + + def speichern(self, layer: Any, anzeigename: str, legende_auswahl: dict | None = None) -> Stilvorlage: + """Speichert die aktuelle Darstellung des Layers; eine gleichnamige Vorlage wird ersetzt. + + Raises + ------ + ValueError + Bei leerem Namen oder wenn QGIS die Datei nicht schreiben konnte. + """ + name = self.dateiname(anzeigename) + if not name: + raise ValueError("Bitte einen Namen für die Stilvorlage angeben.") + os.makedirs(self.ordner, exist_ok=True) + qml_pfad, json_pfad = self._pfade(name) + ergebnis = layer.saveNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG) + meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis)) + if not ok: + raise ValueError(f"Die Stilvorlage konnte nicht gespeichert werden: {meldung}") + erstellt = datetime.now().isoformat(timespec="seconds") + meta = { + "anzeigename": anzeigename.strip(), + "erstellt": erstellt, + "layer": layer.name(), + "stile": _stilnamen(layer), + } + if legende_auswahl: + meta["legende_auswahl"] = legende_auswahl + with open(json_pfad, "w", encoding="utf-8") as datei: + json.dump(meta, datei, ensure_ascii=False, indent=1) + return Stilvorlage(name, anzeigename.strip(), qml_pfad, erstellt, name == self.standard()) + + def laden(self, layer: Any, name: str) -> None: + """Ersetzt Symbologie und Beschriftung des Layers durch die Vorlage. + + Raises + ------ + ValueError + Wenn die Vorlage fehlt oder QGIS sie nicht laden konnte. + """ + qml_pfad, _ = self._pfade(name) + if not os.path.exists(qml_pfad): + raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht mehr.") + ergebnis = layer.loadNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG) + meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis)) + if not ok: + raise ValueError(f"Die Stilvorlage konnte nicht geladen werden: {meldung}") + nachzeichnen = getattr(layer, "triggerRepaint", None) + if callable(nachzeichnen): + nachzeichnen() + + def loeschen(self, name: str) -> None: + for pfad in self._pfade(name): + if os.path.exists(pfad): + os.remove(pfad) + if self.standard() == name: + self.einstellungen.remove(STANDARD_SCHLUESSEL) + + def setze_standard(self, name: str) -> None: + if not os.path.exists(self._pfade(name)[0]): + raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht.") + self.einstellungen.setValue(STANDARD_SCHLUESSEL, name) diff --git a/modules/print_layout.py b/modules/print_layout.py index 4e23932..e7e23af 100644 --- a/modules/print_layout.py +++ b/modules/print_layout.py @@ -981,6 +981,7 @@ class PrintLayout: kartenname_auswahl: str = "", thema: str = "", fit_extent_to_map: bool = True, + basemap_legende: bool = False, ) -> Any: layout_manager = self.project.layoutManager() existing_layout = layout_manager.layoutByName(name) @@ -1082,7 +1083,29 @@ class PrintLayout: kartenname_auswahl, ) + if basemap_legende: + self._erzeuge_basemap_legende(layout) + layout_manager.addLayout(layout) self._current_layout = None open_layout_designer(layout) return layout + + def _erzeuge_basemap_legende(self, layout: Any) -> None: + """Kennzeichnet das Layout und erzeugt die Basemap-Legende (Basis #88). + + Ein Fehler bei der Basemap-Legende darf das Anlegen der Vorlage nicht verhindern; er wird + als Warnung gemeldet. + """ + from sn_basis.functions.message_wrapper import warning + from sn_basis.modules.basemap_legende import BasemapLegende + from sn_basis.modules.basemap_legende_layout import markiere_layout + + markiere_layout(layout) + try: + ergebnis = BasemapLegende(self.project).aktualisiere(layout) + except Exception as fehler: + warning("Basemap-Legende", f"Die Basemap-Legende konnte nicht erzeugt werden: {fehler}") + return + if ergebnis.get("status") == "keine_eintraege": + warning("Basemap-Legende", ergebnis.get("meldung", "")) diff --git a/modules/print_logic.py b/modules/print_logic.py index cbcf534..4116855 100644 --- a/modules/print_logic.py +++ b/modules/print_logic.py @@ -69,6 +69,8 @@ DRUCKRECHTECK_LAYER_NAME = "Druckrechteck" KARTENNAME_38 = "§38" KARTENNAME_41 = "§41" KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe" +# Layouts, die beim Anlegen eine Basemap-Legende erhalten (Basis #88). Neue Layouts hier aufnehmen. +LAYOUTS_MIT_BASEMAP_LEGENDE = frozenset({KARTENNAME_38, KARTENNAME_41}) MASSSTAB_WIE_KARTENFENSTER = "Wie Kartenfenster" THEMA_WIE_KARTENFENSTER = "wie kartenfenster" @@ -1188,6 +1190,12 @@ class PrintLogic: fallback_map_h = max(map_height_mm, fallback_page_h - STANDARD_LAYOUT_FIXHOEHE_MM, 50.0) return fallback_page_w, fallback_page_h, fallback_map_w, fallback_map_h, True + def _basemap_legende_erzeugen(self, kartenname_auswahl: str) -> bool: + """Ob die neue Vorlage eine Basemap-Legende bekommt (Layoutart und Schalter im Druck-Tab).""" + from sn_basis.modules.basemap_legende import legende_erzeugen_aktiv + + return kartenname_auswahl in LAYOUTS_MIT_BASEMAP_LEGENDE and legende_erzeugen_aktiv() + def druckvorlage_anlegen( self, layer: object | None, @@ -1326,6 +1334,7 @@ class PrintLogic: kartenname_auswahl=kartenname_auswahl, thema=thema, fit_extent_to_map=(druckbereich_auswahl != DRUCKBEREICH_VERFAHRENSGEBIET) or force_fit_extent_for_min_a4, + basemap_legende=self._basemap_legende_erzeugen(kartenname_auswahl), ) except ValueError as exc: self.pruefmanager.zeige_hinweis("Vorlage anlegen", str(exc)) diff --git a/tests/test_basemap_anbindung.py b/tests/test_basemap_anbindung.py new file mode 100644 index 0000000..253390c --- /dev/null +++ b/tests/test_basemap_anbindung.py @@ -0,0 +1,174 @@ +""" +Tests für die Anbindung der Basemap-Legende an Druckablauf, Dialog und Druck-Tab (Basis #88): +Mehrfachauswahl-Dialog, PrintLogic/PrintLayout und Basemap-Bereich im Druck-Tab. +""" +import json +import unittest +from unittest.mock import MagicMock, patch + +from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl +from sn_basis.functions.variable_wrapper import get_variable, set_variable +from sn_basis.modules import basemap_legende as bl +from sn_basis.modules.print_layout import PrintLayout +from sn_basis.modules.print_logic import ( + KARTENNAME_38, + KARTENNAME_41, + KARTENNAME_BAUFREIGABE, + LAYOUTS_MIT_BASEMAP_LEGENDE, + PrintLogic, +) +from sn_basis.ui.tabs.print_tab import PrintTab +from sn_basis.ui.widgets.basemap_bereich import BasemapBereich + + +class TestMehrfachauswahl(unittest.TestCase): + + def test_mock_modus_liefert_vorauswahl_in_listenreihenfolge(self): + """Prüft, dass der Mehrfachauswahl-Dialog im Mock-Modus die vorausgewählten Schlüssel in Listenreihenfolge liefert.""" + eintraege = [{"schluessel": s, "text": s.upper()} for s in ("a", "b", "c")] + self.assertEqual(ask_mehrfachauswahl("T", "H", eintraege, {"c", "a"}), ["a", "c"]) + + +class TestDruckanbindung(unittest.TestCase): + + def tearDown(self): + set_variable(bl.LEGENDE_ERZEUGEN_VAR, "", scope="project") + + def test_basemap_legende_nur_fuer_38_und_41_und_bei_eingeschaltetem_schalter(self): + """Prüft, dass nur §-38- und §-41-Vorlagen eine Basemap-Legende bekommen und nur, wenn der Schalter im Druck-Tab an ist.""" + logik = PrintLogic(pruefmanager=MagicMock()) + self.assertEqual(LAYOUTS_MIT_BASEMAP_LEGENDE, {KARTENNAME_38, KARTENNAME_41}) + self.assertTrue(logik._basemap_legende_erzeugen(KARTENNAME_41)) + self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_BAUFREIGABE)) + bl.setze_legende_erzeugen(False) + self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_38)) + + def test_layout_wird_markiert_und_legende_erzeugt(self): + """Prüft, dass PrintLayout das Layout als Basemap-Nutzer kennzeichnet und die Basemap-Legende erzeugen lässt.""" + layout = MagicMock() + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse: + klasse.return_value.aktualisiere.return_value = {"status": "ok"} + PrintLayout(project=MagicMock())._erzeuge_basemap_legende(layout) + layout.setCustomProperty.assert_any_call("sn_basemap_legende", 1) + klasse.return_value.aktualisiere.assert_called_once_with(layout) + + def test_fehler_der_basemap_legende_verhindert_die_vorlage_nicht(self): + """Prüft, dass ein Fehler beim Erzeugen der Basemap-Legende nur als Warnung gemeldet wird und keine Ausnahme durchreicht.""" + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse, \ + patch("sn_basis.functions.message_wrapper.warning") as warnung: + klasse.return_value.aktualisiere.side_effect = RuntimeError("Netz weg") + PrintLayout(project=MagicMock())._erzeuge_basemap_legende(MagicMock()) + warnung.assert_called_once() + self.assertIn("Netz weg", warnung.call_args[0][1]) + + def test_druck_tab_aktualisiert_basemap_bereich_falls_vorhanden(self): + """Prüft, dass der Druck-Tab den Basemap-Bereich neu einliest und ohne Bereich nichts tut.""" + tab = PrintTab.__new__(PrintTab) + tab._basemap_bereich_aktualisieren() # ohne Bereich: keine Ausnahme + tab._basemap_bereich = MagicMock() + tab._basemap_bereich_aktualisieren() + tab._basemap_bereich.aktualisieren.assert_called_once() + + +def _bereich(stil_name="Ohne_Hausnummern", layer=None, layout_name="§41"): + bereich = BasemapBereich.__new__(BasemapBereich) + bereich._project = MagicMock() + bereich._stilvorlagen = MagicMock() + bereich._stil_combo = MagicMock() + bereich._stil_combo.currentData.return_value = stil_name + bereich._stil_combo.currentText.return_value = stil_name + bereich._layout_combo = MagicMock() + bereich._layout_combo.currentText.return_value = layout_name + bereich._bereich_combo = MagicMock() + bereich.basemap_layer = MagicMock(return_value=layer) + bereich.aktualisieren = MagicMock() + return bereich + + +class TestBasemapBereich(unittest.TestCase): + + def tearDown(self): + for schluessel in (bl.AUSWAHL_VAR, bl.AUSWERTEBEREICH_VAR): + set_variable(schluessel, "", scope="project") + + def test_auswertebereich_wird_gespeichert(self): + """Prüft, dass die Wahl des Auswertebereichs in der Projektvariable landet.""" + bereich = _bereich() + bereich._bereich_combo.currentData.return_value = bl.AUSWERTEBEREICH_DRUCKBEREICH + bereich._on_bereich_geaendert(1) + self.assertEqual(bl.lies_auswertebereich(), bl.AUSWERTEBEREICH_DRUCKBEREICH) + + @patch("sn_basis.ui.widgets.basemap_bereich.warning") + def test_aktualisieren_ohne_vorlage_warnt(self, warnung): + """Prüft, dass „Basemap-Legende aktualisieren“ ohne vorhandene Vorlage warnt und nichts auswertet.""" + bereich = _bereich() + bereich._project.layoutManager.return_value.layoutByName.return_value = None + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse: + bereich._on_legende_aktualisieren() + klasse.assert_not_called() + warnung.assert_called_once() + + @patch("sn_basis.ui.widgets.basemap_bereich.success") + def test_aktualisieren_wertet_gewaehlte_vorlage_aus(self, erfolg): + """Prüft, dass „Basemap-Legende aktualisieren“ die gewählte Vorlage auswertet und den Erfolg meldet.""" + bereich = _bereich() + layout = bereich._project.layoutManager.return_value.layoutByName.return_value + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse: + klasse.return_value.aktualisiere.return_value = {"status": "ok", "meldung": "3 Einträge"} + bereich._on_legende_aktualisieren() + klasse.return_value.aktualisiere.assert_called_once_with(layout) + erfolg.assert_called_once() + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=True) + @patch("sn_basis.ui.widgets.basemap_bereich.success") + def test_stil_laden_uebernimmt_legendenauswahl(self, _erfolg, _frage): + """Prüft, dass das Laden einer Stilvorlage den Layerstil ersetzt und ihre Legendenauswahl in das Projekt übernimmt.""" + layer = MagicMock() + bereich = _bereich(layer=layer) + bereich._stilvorlagen.pruefe_passung.return_value = [] + bereich._stilvorlagen.legendenauswahl.return_value = {"ausgeblendet": ["wald"], "eingeblendet": []} + bereich._on_stil_laden() + bereich._stilvorlagen.laden.assert_called_once_with(layer, "Ohne_Hausnummern") + self.assertEqual(json.loads(get_variable(bl.AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"]) + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", side_effect=[True, False]) + def test_stil_laden_bricht_bei_fremder_vorlage_ab(self, _frage): + """Prüft, dass eine Vorlage mit unbekannten Styles nur nach Bestätigung geladen wird.""" + bereich = _bereich(layer=MagicMock()) + bereich._stilvorlagen.pruefe_passung.return_value = ["Fremder_Style"] + bereich._on_stil_laden() + bereich._stilvorlagen.laden.assert_not_called() + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Ohne Hausnummern", True)) + @patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=False) + def test_vorhandene_vorlage_wird_nur_nach_rueckfrage_ersetzt(self, _frage, _text): + """Prüft, dass eine gleichnamige Stilvorlage ohne Bestätigung nicht überschrieben wird.""" + bereich = _bereich(layer=MagicMock()) + bereich._stilvorlagen.vorhanden.return_value = True + bereich._on_stil_speichern() + bereich._stilvorlagen.speichern.assert_not_called() + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Neu", True)) + @patch("sn_basis.ui.widgets.basemap_bereich.success") + def test_speichern_mit_aktueller_legendenauswahl(self, _erfolg, _text): + """Prüft, dass eine neue Stilvorlage zusammen mit der aktuellen Legendenauswahl gespeichert wird.""" + layer = MagicMock() + bereich = _bereich(layer=layer) + bereich._stilvorlagen.vorhanden.return_value = False + bl.merke_auswahl([bl.LegendenEintrag("wald", "Wald", "Vegetation", [])], []) + bereich._on_stil_speichern() + bereich._stilvorlagen.speichern.assert_called_once_with( + layer, "Neu", {"ausgeblendet": ["wald"], "eingeblendet": []}) + bereich.aktualisieren.assert_called_once() + + @patch("sn_basis.ui.widgets.basemap_bereich.warning") + def test_stil_speichern_ohne_basemap_layer_warnt(self, warnung): + """Prüft, dass ohne Basemap-Layer keine Stilvorlage gespeichert, sondern gewarnt wird.""" + bereich = _bereich(layer=None) + bereich._on_stil_speichern() + bereich._stilvorlagen.speichern.assert_not_called() + warnung.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_basemap_kacheln.py b/tests/test_basemap_kacheln.py new file mode 100644 index 0000000..343dd89 --- /dev/null +++ b/tests/test_basemap_kacheln.py @@ -0,0 +1,186 @@ +""" +Tests für modules/basemap_kacheln.py – kachelweise Auswertung des Basemap-Layers (Basis #88). + +Layer, Kachelmatrix und Geometrie sind Fakes; ``selectByGeometry`` liefert je Kachel vorbereitete +Objekte. Die echte Auswertung gegen basemap.de ist im QGIS-Test geprüft. +""" +import unittest +from unittest.mock import patch + +from sn_basis.modules.basemap_kacheln import anzahl_kacheln, sammle_objekte, stile_aus_layer, zoomstufen + + +class _Rechteck: + def __init__(self, x0, y0, x1, y1): + self.werte = (x0, y0, x1, y1) + + def xMinimum(self): return self.werte[0] + def yMinimum(self): return self.werte[1] + def xMaximum(self): return self.werte[2] + def yMaximum(self): return self.werte[3] + def width(self): return self.werte[2] - self.werte[0] + + +class _Bereich: + def __init__(self, spalten, zeilen): + self._spalten, self._zeilen = spalten, zeilen + + def startColumn(self): return self._spalten[0] + def endColumn(self): return self._spalten[1] + def startRow(self): return self._zeilen[0] + def endRow(self): return self._zeilen[1] + + +class _Matrix: + def __init__(self, bereich): + self._bereich = bereich + + def tileRangeFromExtent(self, ausdehnung): + return self._bereich + + def tileExtent(self, kachel): + return _Rechteck(kachel.column() * 10, kachel.row() * 10, kachel.column() * 10 + 10, kachel.row() * 10 + 10) + + +class _MatrixSet: + def __init__(self, bereich): + self._bereich = bereich + self.gefragt = [] + + def scaleToZoomLevel(self, massstab, begrenzen=True): + self.gefragt.append((massstab, begrenzen)) + return 15 if begrenzen else 16 + + def scaleToZoom(self, massstab): + return 15.8 + + def tileMatrix(self, zoom): + return _Matrix(self._bereich) + + +class _Geometrie: + def __init__(self, leer=False): + self._leer = leer + + def boundingBox(self): + return _Rechteck(0, 0, 20, 10) + + def intersection(self, andere): + return _Geometrie(self._leer) + + def isEmpty(self): + return self._leer + + +class _Feld: + def __init__(self, name): self._name = name + def name(self): return self._name + + +class _Merkmal: + def __init__(self, layer, geometrietyp, **attribute): + self._werte = dict(attribute, tile_layer=layer, tile_zoom=15) + self._typ = geometrietyp + + def fields(self): return [_Feld(n) for n in self._werte] + def __getitem__(self, name): return self._werte[name] + def geometry(self): return type("G", (), {"type": lambda s: self._typ})() + + +class _Layer: + def __init__(self, bereich, merkmale_je_aufruf): + self.matrix = _MatrixSet(bereich) + self._merkmale = list(merkmale_je_aufruf) + self._aktuell = [] + self.auswahl_aufgehoben = 0 + self.aufrufe = 0 + + def tileMatrixSet(self): return self.matrix + + def selectByGeometry(self, geometrie, kontext, verhalten, beziehung): + self.aufrufe += 1 + self._aktuell = self._merkmale.pop(0) if self._merkmale else [] + + def selectedFeatures(self): return self._aktuell + def removeSelection(self): self.auswahl_aufgehoben += 1 + + +class _GeometrieFabrik: + """Ersatz für QgsGeometry.fromRect (fehlt im Mock-Modus).""" + + @staticmethod + def fromRect(rechteck): + return rechteck + + +@patch("sn_basis.modules.basemap_kacheln.QgsGeometry", _GeometrieFabrik) +class TestSammleObjekte(unittest.TestCase): + + def test_gleiche_objekte_aus_mehreren_kacheln_werden_entdoppelt(self): + """Prüft, dass Objekte mit gleichem Quell-Layer, Geometrietyp und Attributen über Kachelgrenzen hinweg nur einmal gezählt werden.""" + strasse = _Merkmal("Verkehrslinie", 1, klasse="Kreisstraße") + layer = _Layer(_Bereich((0, 1), (0, 0)), [ + [strasse, _Merkmal("Vegetationsflaeche", 2, klasse="Wald")], + [_Merkmal("Verkehrslinie", 1, klasse="Kreisstraße")], + ]) + objekte = sammle_objekte(layer, _Geometrie(), 5000.0) + self.assertEqual(layer.aufrufe, 2) + self.assertEqual(sorted(o.layer_name for o in objekte), ["Vegetationsflaeche", "Verkehrslinie"]) + kreis = next(o for o in objekte if o.layer_name == "Verkehrslinie") + self.assertEqual(kreis.attribute, {"klasse": "Kreisstraße"}) + self.assertIs(kreis.merkmal, strasse) + self.assertEqual(layer.auswahl_aufgehoben, 1) + + def test_kacheln_ausserhalb_der_geometrie_werden_uebersprungen(self): + """Prüft, dass Kacheln ohne Schnitt mit dem Auswertebereich nicht abgefragt werden.""" + layer = _Layer(_Bereich((0, 2), (0, 1)), []) + self.assertEqual(sammle_objekte(layer, _Geometrie(leer=True), 5000.0), []) + self.assertEqual(layer.aufrufe, 0) + + def test_abbruch_liefert_none_und_hebt_auswahl_auf(self): + """Prüft, dass ein Abbruch über den Fortschritt None liefert und die Auswahl auf dem Basemap-Layer trotzdem aufgehoben wird.""" + layer = _Layer(_Bereich((0, 3), (0, 0)), [[], [], [], []]) + fortschritt = [] + + def melde(nummer, gesamt): + fortschritt.append((nummer, gesamt)) + return nummer < 2 + + self.assertIsNone(sammle_objekte(layer, _Geometrie(), 5000.0, fortschritt=melde)) + self.assertEqual(fortschritt, [(1, 4), (2, 4)]) + self.assertEqual(layer.aufrufe, 1) + self.assertEqual(layer.auswahl_aufgehoben, 1) + + def test_anzahl_kacheln_aus_kachelbereich(self): + """Prüft, dass die Kachelzahl aus dem Kachelbereich der Geometrie berechnet wird.""" + self.assertEqual(anzahl_kacheln(_Layer(_Bereich((3, 5), (7, 10)), []), _Geometrie(), 5000.0), 12) + + +class TestZoomUndStile(unittest.TestCase): + + def test_zoomstufen_wie_im_qgis_renderer(self): + """Prüft, dass Kachelzoom begrenzt, Darstellungszoom unbegrenzt und der gebrochene Zoom aus dem Maßstab bestimmt werden.""" + layer = _Layer(_Bereich((0, 0), (0, 0)), []) + self.assertEqual(zoomstufen(layer, 5000.0), (15, 16, 15.8)) + self.assertEqual(layer.matrix.gefragt, [(5000.0, True), (5000.0, False)]) + + def test_stile_aus_layer_liest_renderer_styles(self): + """Prüft, dass Name, Quell-Layer, Geometrietyp, Zoombereich, Schalter und Filter der Renderer-Styles übernommen werden.""" + class Stil: + def styleName(self): return "Decker_Kreisstr" + def layerName(self): return "Verkehrslinie" + def geometryType(self): return 1 + def minZoomLevel(self): return 9 + def maxZoomLevel(self): return 21 + def isEnabled(self): return False + def filterExpression(self): return "\"klasse\" IS 'Kreisstraße'" + + layer = type("L", (), {"renderer": lambda s: type("R", (), {"styles": lambda s: [Stil()]})()})() + stil = stile_aus_layer(layer)[0] + self.assertEqual((stil.name, stil.layer_name, stil.geometrie, stil.min_zoom, stil.max_zoom, stil.aktiv), + ("Decker_Kreisstr", "Verkehrslinie", 1, 9, 21, False)) + self.assertIn("Kreisstraße", stil.filter) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_basemap_katalog.py b/tests/test_basemap_katalog.py new file mode 100644 index 0000000..99a0d2f --- /dev/null +++ b/tests/test_basemap_katalog.py @@ -0,0 +1,174 @@ +""" +Tests für modules/basemap_katalog.py – Legendenkatalog aus der basemap.de-Style-JSON (Basis #88). + +Die Style-JSON wird nicht geladen; die Tests arbeiten mit einem kleinen Ausschnitt mit den +typischen Mustern (Kontur/Decker, Brücke, Beschriftung, Symbol, Hintergrund). +""" +import copy +import unittest + +from sn_basis.modules.basemap_katalog import ( + erzeuge_katalog, + ist_ausgeschlossen, + ist_renderer_stil, + lade_json, + normalisiere_stil_id, + pruefe_katalog, + waehle_symbol_stile, + _standard_zielpfad, +) + +_KREIS = ["==", "klasse", "Kreisstraße"] + +STIL_AUSSCHNITT = { + "name": "Ausschnitt", + "version": 8, + "layers": [ + {"id": "Hintergrund", "type": "fill", "source-layer": "Hintergrund"}, + {"id": "VegetationsF_Wald", "type": "fill", "source-layer": "Vegetationsflaeche", + "filter": ["==", "klasse", "Wald"]}, + {"id": "Kontur_Kreisstr", "type": "line", "source-layer": "Verkehrslinie", + "filter": ["all", _KREIS, ["!has", "bauwerk"]]}, + {"id": "Kontur_Kreisstr_getrennt", "type": "line", "source-layer": "Verkehrslinie", + "filter": ["all", _KREIS, ["==", "fahrbahn", "Getrennt"]]}, + {"id": "Decker_Kreisstr", "type": "line", "source-layer": "Verkehrslinie", + "filter": ["all", _KREIS, ["!has", "bauwerk"]]}, + {"id": "Bruecke_Kreisstr_Decker", "type": "line", "source-layer": "Verkehrslinie", + "filter": ["all", _KREIS, ["==", "bauwerk", "Brücke"]]}, + {"id": "ImBau_Decker_Kreisstr", "type": "line", "source-layer": "Verkehrslinie", + "filter": ["all", _KREIS, ["==", "zustand", "Im Bau"]]}, + {"id": "Name_Kreisstr", "type": "symbol", "source-layer": "Verkehrslinie", + "layout": {"text-field": "{name}"}, "filter": _KREIS}, + {"id": "Nummer_Kreisstr", "type": "symbol", "source-layer": "Verkehrslinie", + "layout": {"text-field": "{nummer}", "icon-image": "schild"}, "filter": _KREIS}, + {"id": "Symbol_BauwerkP_Kran", "type": "symbol", "source-layer": "Bauwerkspunkt", + "layout": {"icon-image": "kran"}, "filter": ["==", "klasse", "Kran"]}, + {"id": "Symbol_BauwerkF_Kran", "type": "symbol", "source-layer": "Bauwerksflaeche", + "layout": {"icon-image": "kran"}, "filter": ["==", "klasse", "Kran"]}, + {"id": "Gebaeude3D", "type": "fill-extrusion", "source-layer": "Gebaeudeflaeche"}, + ], +} + + +def _eintrag(katalog, stil_id): + return next(e for e in katalog["eintraege"] if stil_id in e["treffer_stile"]) + + +class TestRendererStil(unittest.TestCase): + + def test_beschriftung_und_3d_sind_keine_renderer_stile(self): + """Prüft, dass reine Beschriftungen und 3D-Gebäude nicht als Renderer-Style gelten, Icon-Symbole und Flächen schon.""" + stile = {s["id"]: s for s in STIL_AUSSCHNITT["layers"]} + self.assertFalse(ist_renderer_stil(stile["Name_Kreisstr"])) + self.assertFalse(ist_renderer_stil(stile["Gebaeude3D"])) + self.assertTrue(ist_renderer_stil(stile["Symbol_BauwerkP_Kran"])) + self.assertTrue(ist_renderer_stil(stile["VegetationsF_Wald"])) + + def test_ausschluss_ueber_liste_und_praefix(self): + """Prüft, dass Style-IDs über die Ausschlussliste und über Präfixe (Nummernschilder) ausgeschlossen werden.""" + self.assertTrue(ist_ausgeschlossen("Hintergrund", ["Hintergrund"], ["Nummer_"])) + self.assertTrue(ist_ausgeschlossen("Nummer_Kreisstr", [], ["Nummer_"])) + self.assertFalse(ist_ausgeschlossen("Decker_Kreisstr", ["Hintergrund"], ["Nummer_"])) + + def test_normalisierung_entfernt_variantenkennungen(self): + """Prüft, dass Variantenkennungen wie Kontur, Brücke oder Breitenangaben beim Normalisieren einer Style-ID entfallen.""" + self.assertEqual(normalisiere_stil_id("Bruecke_Kreisstr_getrennt_Decker"), "Kreisstr") + self.assertEqual(normalisiere_stil_id("Gewaesser_L_Breite_12m"), "Gewaesser_Breite") + + +class TestErzeugeKatalog(unittest.TestCase): + + def test_kontur_decker_und_bruecke_bilden_einen_eintrag(self): + """Prüft, dass Kontur-, Decker-, Fahrbahn- und Brückenvarianten derselben Straßenklasse genau einen Legendeneintrag bilden.""" + katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT) + kreis = _eintrag(katalog, "Kontur_Kreisstr") + self.assertEqual(kreis["bezeichnung"], "Kreisstraße") + self.assertEqual(set(kreis["treffer_stile"]), + {"Kontur_Kreisstr", "Kontur_Kreisstr_getrennt", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"}) + self.assertEqual(kreis["symbol_stile"], ["Kontur_Kreisstr", "Decker_Kreisstr"]) + + def test_zustand_im_bau_wird_eigener_eintrag(self): + """Prüft, dass „im Bau“ einen eigenen Legendeneintrag mit Zustandsangabe in der Bezeichnung bekommt.""" + katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT) + im_bau = _eintrag(katalog, "ImBau_Decker_Kreisstr") + self.assertNotIn("Kontur_Kreisstr", im_bau["treffer_stile"]) + self.assertIn("im Bau", im_bau["bezeichnung"]) + + def test_punkt_und_flaeche_derselben_objektart_werden_zusammengefasst(self): + """Prüft, dass das Kran-Symbol aus Bauwerkspunkt und Bauwerksfläche in einem Eintrag landet.""" + katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT) + kran = _eintrag(katalog, "Symbol_BauwerkP_Kran") + self.assertIn("Symbol_BauwerkF_Kran", kran["treffer_stile"]) + + def test_ausgeschlossene_stile_fehlen_im_katalog(self): + """Prüft, dass Hintergrund, Beschriftungen, Nummernschilder und 3D-Gebäude keinem Eintrag zugeordnet werden.""" + katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT) + alle = {s for e in katalog["eintraege"] for s in e["treffer_stile"]} + for stil_id in ("Hintergrund", "Name_Kreisstr", "Nummer_Kreisstr", "Gebaeude3D"): + self.assertNotIn(stil_id, alle) + self.assertEqual(pruefe_katalog(katalog), []) + + def test_fachliche_nachbearbeitung_bleibt_beim_erneuten_lauf_erhalten(self): + """Prüft, dass Bezeichnung, Gruppe, Zusammenführung und Symbolwahl eines bestehenden Katalogs bei einem erneuten Lauf erhalten bleiben.""" + katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT) + bearbeitet = copy.deepcopy(katalog) + kreis = _eintrag(bearbeitet, "Kontur_Kreisstr") + im_bau = _eintrag(bearbeitet, "ImBau_Decker_Kreisstr") + kreis["bezeichnung"] = "Kreisstraße (fachlich)" + kreis["treffer_stile"] += im_bau["treffer_stile"] + kreis["symbol_stile"] = ["Decker_Kreisstr"] + bearbeitet["eintraege"].remove(im_bau) + + erneut, meldungen = erzeuge_katalog(STIL_AUSSCHNITT, bearbeitet) + + kreis_neu = _eintrag(erneut, "Kontur_Kreisstr") + self.assertEqual(kreis_neu["bezeichnung"], "Kreisstraße (fachlich)") + self.assertIn("ImBau_Decker_Kreisstr", kreis_neu["treffer_stile"]) + self.assertEqual(kreis_neu["symbol_stile"], ["Decker_Kreisstr"]) + self.assertEqual(meldungen, []) + + def test_entfallene_style_ids_werden_gemeldet(self): + """Prüft, dass eine Style-ID, die in der neuen Style-JSON fehlt, als entfallen gemeldet und aus dem Katalog entfernt wird.""" + katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT) + verkleinert = copy.deepcopy(STIL_AUSSCHNITT) + verkleinert["layers"] = [l for l in verkleinert["layers"] if l["id"] != "Bruecke_Kreisstr_Decker"] + + erneut, meldungen = erzeuge_katalog(verkleinert, katalog) + + self.assertTrue(any("Bruecke_Kreisstr_Decker" in m for m in meldungen)) + self.assertNotIn("Bruecke_Kreisstr_Decker", _eintrag(erneut, "Kontur_Kreisstr")["treffer_stile"]) + + +class TestSymbolwahlUndPruefung(unittest.TestCase): + + def test_symbol_stile_je_rolle_nur_grundvariante(self): + """Prüft, dass für das Legendensymbol je Rolle (Kontur, Decker) nur die erste Grundvariante gewählt wird.""" + gewaehlt = waehle_symbol_stile( + ["Kontur_Kreisstr_FSZ1", "Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"]) + self.assertEqual(gewaehlt, ["Kontur_Kreisstr", "Decker_Kreisstr"]) + + def test_pruefung_findet_doppelte_zuordnung_und_unbekannte_symbole(self): + """Prüft, dass die Katalogprüfung doppelt zugeordnete Style-IDs und Symbol-Styles außerhalb des Katalogs meldet.""" + katalog = { + "gruppen": ["Verkehr"], + "eintraege": [ + {"schluessel": "a", "gruppe": "Verkehr", "treffer_stile": ["X"], "symbol_stile": ["X"]}, + {"schluessel": "b", "gruppe": "Verkehr", "treffer_stile": ["X"], "symbol_stile": ["Y"]}, + ], + } + fehler = pruefe_katalog(katalog) + self.assertTrue(any("Style-ID X" in f for f in fehler)) + self.assertTrue(any("nicht im Katalog: Y" in f for f in fehler)) + + def test_ausgelieferter_katalog_ist_konsistent(self): + """Prüft, dass der ausgelieferte Katalog data/basemap_legende.json ohne Fehler geprüft wird und Kreisstraße mit Kontur und Decker enthält.""" + katalog = lade_json(_standard_zielpfad()) + self.assertEqual(pruefe_katalog(katalog), []) + kreis = _eintrag(katalog, "Decker_Kreisstr") + self.assertEqual(kreis["bezeichnung"], "Kreisstraße") + self.assertEqual(kreis["symbol_stile"], ["Kontur_Kreisstr", "Decker_Kreisstr"]) + self.assertIn("Hintergrund", katalog["nie_in_legende"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_basemap_legende.py b/tests/test_basemap_legende.py new file mode 100644 index 0000000..2e8c985 --- /dev/null +++ b/tests/test_basemap_legende.py @@ -0,0 +1,327 @@ +""" +Tests für modules/basemap_legende.py – Zuordnung, Auswahl und Ablauf der Basemap-Legende (Basis #88). + +Die Kachelauswertung und die Layoutausgabe werden durch Fakes ersetzt; die echte Auswertung +gegen basemap.de ist im QGIS-Test geprüft (siehe Pull Request). +""" +import json +import unittest +from unittest.mock import patch + +from sn_basis.functions.variable_wrapper import get_variable, set_variable +from sn_basis.modules import basemap_legende as bl +from sn_basis.modules.basemap_legende import ( + AUSWAHL_VAR, + AUSWERTEBEREICH_DRUCKBEREICH, + AUSWERTEBEREICH_VAR, + AUSWERTEBEREICH_VERFAHRENSGEBIET, + BasemapLegende, + Katalog, + LEGENDE_ERZEUGEN_VAR, + LegendenEintrag, + ObjektInfo, + StilInfo, + bilde_eintraege, + ermittle_getroffene_stile, + ist_basemap_layer, + legende_erzeugen_aktiv, + lies_auswahl, + lies_auswertebereich, + merke_auswahl, + setze_auswertebereich, + sichtbare_layer_der_karte, + stil_ist_aktiv, + vorauswahl, +) + +KATALOG = Katalog({ + "gruppen": ["Verkehr", "Vegetation"], + "nie_in_legende": ["Hintergrund"], + "nie_in_legende_praefixe": ["Nummer_"], + "eintraege": [ + {"schluessel": "kreis", "bezeichnung": "Kreisstraße", "gruppe": "Verkehr", + "treffer_stile": ["Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"], + "symbol_stile": ["Kontur_Kreisstr", "Decker_Kreisstr"]}, + {"schluessel": "wald", "bezeichnung": "Wald", "gruppe": "Vegetation", + "treffer_stile": ["VegetationsF_Wald"], "symbol_stile": ["VegetationsF_Wald"]}, + {"schluessel": "weg", "bezeichnung": "Wirtschaftsweg", "gruppe": "Verkehr", + "treffer_stile": ["Kontur_Wirtschaftsweg"], "symbol_stile": ["Kontur_Wirtschaftsweg"]}, + ], +}) + + +def _filter_ueber_attribute(filter_text, objekt): + """Einfache Filterprüfung für Tests: Filter der Form klasse=Wert.""" + feld, wert = filter_text.split("=") + return objekt.attribute.get(feld) == wert + + +def _leere_variablen(): + for schluessel in (AUSWAHL_VAR, AUSWERTEBEREICH_VAR, LEGENDE_ERZEUGEN_VAR): + set_variable(schluessel, "", scope="project") + + +class TestStileUndEintraege(unittest.TestCase): + + def test_stil_aktiv_nur_eingeschaltet_und_im_zoombereich(self): + """Prüft, dass ein Style nur aktiv ist, wenn er eingeschaltet ist und die Zoomstufe im Bereich liegt (-1 = unbegrenzt).""" + stil = StilInfo("A", "L", 1, min_zoom=11, max_zoom=14) + self.assertTrue(stil_ist_aktiv(stil, 11)) + self.assertFalse(stil_ist_aktiv(stil, 15)) + self.assertFalse(stil_ist_aktiv(StilInfo("A", "L", 1, aktiv=False), 12)) + self.assertTrue(stil_ist_aktiv(StilInfo("A", "L", 1), 20)) + + def test_getroffene_stile_pruefen_layer_geometrie_zoom_und_filter(self): + """Prüft, dass ein Style nur Objekte seines Quell-Layers und Geometrietyps zählt, deren Filter zutrifft, und nur bei passender Zoomstufe.""" + stile = [ + StilInfo("Decker_Kreisstr", "Verkehrslinie", 1, filter="klasse=Kreisstraße"), + StilInfo("Decker_Bundesstr", "Verkehrslinie", 1, filter="klasse=Bundesstraße"), + StilInfo("VegetationsF_Wald", "Vegetationsflaeche", 2, filter="klasse=Wald"), + StilInfo("Nur_klein", "Verkehrslinie", 1, max_zoom=10), + StilInfo("Alle_Layer", "", 2), + ] + objekte = [ + ObjektInfo("Verkehrslinie", 1, {"klasse": "Kreisstraße"}), + ObjektInfo("Vegetationsflaeche", 2, {"klasse": "Wald"}), + ObjektInfo("Vegetationsflaeche", 1, {"klasse": "Wald"}), # Linie: passt nicht zum Flächenstyle + ] + getroffen = ermittle_getroffene_stile(objekte, stile, 15, _filter_ueber_attribute) + self.assertEqual(getroffen, {"Decker_Kreisstr": 1, "VegetationsF_Wald": 1, "Alle_Layer": 1}) + + def test_eintraege_fassen_styles_zusammen_und_sortieren_nach_gruppe(self): + """Prüft, dass mehrere Styles eines Katalogeintrags einen Eintrag ergeben und die Einträge nach Gruppen- und Katalogreihenfolge sortiert sind.""" + eintraege = bilde_eintraege( + ["VegetationsF_Wald", "Kontur_Wirtschaftsweg", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"], KATALOG) + self.assertEqual([e.schluessel for e in eintraege], ["kreis", "weg", "wald"]) + self.assertEqual(eintraege[0].treffer, ["Decker_Kreisstr", "Bruecke_Kreisstr_Decker"]) + self.assertEqual(eintraege[0].symbol_stile, ["Kontur_Kreisstr", "Decker_Kreisstr"]) + + def test_ausgeschlossene_und_unbekannte_styles(self): + """Prüft, dass ausgeschlossene Styles entfallen und unbekannte Styles als nicht bekannter Eintrag in „Sonstiges“ landen.""" + eintraege = bilde_eintraege(["Hintergrund", "Nummer_B6", "Eigener_Style"], KATALOG) + self.assertEqual(len(eintraege), 1) + self.assertEqual(eintraege[0].schluessel, "stil:Eigener_Style") + self.assertEqual(eintraege[0].gruppe, "Sonstiges") + self.assertFalse(eintraege[0].bekannt) + + +class TestAuswahlUndEinstellungen(unittest.TestCase): + + def setUp(self): + _leere_variablen() + + def tearDown(self): + _leere_variablen() + + def test_vorauswahl_bekannte_an_unbekannte_aus(self): + """Prüft, dass ohne gespeicherte Auswahl Katalogeinträge vorausgewählt und unbekannte Styles abgewählt sind.""" + eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []), + LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)] + self.assertEqual(vorauswahl(eintraege), {"kreis"}) + + def test_auswahl_wird_gemerkt_und_vorbelegt(self): + """Prüft, dass ab- und angewählte Einträge in der Projektvariable gespeichert und beim nächsten Mal so vorbelegt werden.""" + eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []), + LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)] + merke_auswahl(eintraege, ["stil:X"]) + + self.assertEqual(lies_auswahl(), {"ausgeblendet": ["kreis"], "eingeblendet": ["stil:X"]}) + self.assertEqual(vorauswahl(eintraege, lies_auswahl()), {"stil:X"}) + + def test_merken_ergaenzt_die_bisherige_auswahl(self): + """Prüft, dass das Merken die Auswahl nicht angezeigter Einträge aus früheren Läufen behält.""" + merke_auswahl([LegendenEintrag("wald", "Wald", "Vegetation", [])], []) + merke_auswahl([LegendenEintrag("kreis", "Kreisstraße", "Verkehr", [])], ["kreis"]) + self.assertEqual(lies_auswahl()["ausgeblendet"], ["wald"]) + + def test_kaputte_projektvariable_ergibt_leere_auswahl(self): + """Prüft, dass eine nicht lesbare Projektvariable als leere Auswahl gilt.""" + set_variable(AUSWAHL_VAR, "{kein json", scope="project") + self.assertEqual(lies_auswahl(), {"ausgeblendet": [], "eingeblendet": []}) + + def test_auswertebereich_standard_und_umstellung(self): + """Prüft, dass der Auswertebereich standardmäßig das Verfahrensgebiet ist, umgestellt werden kann und unbekannte Werte abgewiesen werden.""" + self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_VERFAHRENSGEBIET) + setze_auswertebereich(AUSWERTEBEREICH_DRUCKBEREICH) + self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_DRUCKBEREICH) + with self.assertRaises(ValueError): + setze_auswertebereich("irgendwo") + + def test_legende_erzeugen_standard_an(self): + """Prüft, dass das Erzeugen der Basemap-Legende standardmäßig eingeschaltet ist und abgeschaltet werden kann.""" + self.assertTrue(legende_erzeugen_aktiv()) + bl.setze_legende_erzeugen(False) + self.assertFalse(legende_erzeugen_aktiv()) + + +class _FakeVektorkachelLayer: + def __init__(self, quelle="", name="Layer"): + self._quelle, self._name = quelle, name + + def tileMatrixSet(self): + return None + + def source(self): + return self._quelle + + def name(self): + return self._name + + def crs(self): + return "EPSG:3857" + + +class _FakeVektorLayer: + def source(self): + return "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/wms" + + def name(self): + return "Basemap.de WMS" + + +class TestLayerErkennung(unittest.TestCase): + + def test_erkennung_ueber_quelle_und_namen(self): + """Prüft, dass der Basemap-Layer an Vektorkacheln mit basemap.de-Quelle erkannt wird, der Name nur als Rückfall dient und andere Layertypen nicht zählen.""" + self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer( + "type=xyz&url=https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/tiles/v2/{z}/{x}/{y}.pbf"))) + self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer("type=mbtiles", "Basemap.de - Farbe"))) + self.assertFalse(ist_basemap_layer(_FakeVektorkachelLayer("type=xyz&url=https://andere.de", "OSM"))) + self.assertFalse(ist_basemap_layer(_FakeVektorLayer())) + self.assertFalse(ist_basemap_layer(None)) + + def test_sichtbare_layer_aus_thema_feste_liste_oder_layerbaum(self): + """Prüft, dass die Layer der Karte aus dem Kartenthema, sonst aus der festen Layerliste, sonst aus dem sichtbaren Layerbaum kommen.""" + class Karte: + def __init__(self, thema="", feste=None): + self._thema, self._feste = thema, feste or [] + def followVisibilityPreset(self): return bool(self._thema) + def followVisibilityPresetName(self): return self._thema + def keepLayerSet(self): return bool(self._feste) + def layers(self): return self._feste + + class Knoten: + def __init__(self, layer, sichtbar): self._layer, self._sichtbar = layer, sichtbar + def layer(self): return self._layer + def isVisible(self): return self._sichtbar + + class Projekt: + def mapThemeCollection(self): + return type("T", (), {"mapThemeVisibleLayers": lambda s, t: ["thema-" + t]})() + def layerTreeRoot(self): + return type("W", (), {"findLayers": lambda s: [Knoten("a", True), Knoten("b", False)]})() + + self.assertEqual(sichtbare_layer_der_karte(Karte(thema="Plan"), Projekt()), ["thema-Plan"]) + self.assertEqual(sichtbare_layer_der_karte(Karte(feste=["x"]), Projekt()), ["x"]) + self.assertEqual(sichtbare_layer_der_karte(Karte(), Projekt()), ["a"]) + + +class _FakeAusgabe: + """Ersatz für basemap_legende_layout im Ablauftest.""" + + def __init__(self, karte): + self.karte = karte + self.geschrieben = None + + def hauptkarte(self, layout): + return self.karte + + def baue_symbole(self, layer, eintraege, zoom, vtz): + return {e.schluessel: f"symbol-{e.schluessel}" for e in eintraege} + + def vorschau_icon(self, symbol): + return None + + def schreibe_legende(self, layout, karte, eintraege, symbole, projekt, *, bereich, massstab): + self.geschrieben = {"eintraege": [e.schluessel for e in eintraege], "bereich": bereich, "massstab": massstab} + return "" + + +class _FakeKarte: + def scale(self): + return 5000.0 + + +class TestAblauf(unittest.TestCase): + + def setUp(self): + _leere_variablen() + self.dialog_aufrufe = [] + self.meldungen = [] + + def tearDown(self): + _leere_variablen() + + def _legende(self, dialog_ergebnis=("kreis",)): + def dialog(titel, hinweis, eintraege, vorauswahl): + self.dialog_aufrufe.append((eintraege, set(vorauswahl))) + return None if dialog_ergebnis is None else list(dialog_ergebnis) + return BasemapLegende(project=object(), katalog=KATALOG, dialog=dialog, + fortschritt_fabrik=lambda *a: None, bestaetigen=lambda *a: True, + melden=lambda titel, text: self.meldungen.append(text)) + + def _starte(self, legende, basemap=True, genutzt=AUSWERTEBEREICH_VERFAHRENSGEBIET, eintraege=None): + ausgabe = _FakeAusgabe(_FakeKarte()) + layer = [_FakeVektorkachelLayer("gdz_basemapde_vektor", "Basemap.de - Farbe")] if basemap else [] + eintraege = eintraege if eintraege is not None else bilde_eintraege( + ["Decker_Kreisstr", "VegetationsF_Wald"], KATALOG) + with patch.dict("sys.modules", {"sn_basis.modules.basemap_legende_layout": ausgabe}), \ + patch("sn_basis.modules.basemap_legende.sichtbare_layer_der_karte", return_value=layer), \ + patch("sn_basis.modules.basemap_legende.auswertegeometrie", return_value=("geometrie", genutzt)), \ + patch("sn_basis.modules.basemap_legende.zoomstufen", return_value=(15, 16, 15.8)), \ + patch.object(BasemapLegende, "ermittle_eintraege", return_value=eintraege): + import sn_basis.modules as paket + with patch.object(paket, "basemap_legende_layout", ausgabe, create=True): + ergebnis = legende.aktualisiere(layout="layout") + return ergebnis, ausgabe + + def test_ohne_basemap_layer_passiert_nichts(self): + """Prüft, dass ohne sichtbaren Basemap-Layer weder Dialog noch Legende entstehen.""" + ergebnis, ausgabe = self._starte(self._legende(), basemap=False) + self.assertEqual(ergebnis["status"], "kein_layer") + self.assertEqual(self.dialog_aufrufe, []) + self.assertIsNone(ausgabe.geschrieben) + + def test_gewaehlte_eintraege_werden_geschrieben_und_gemerkt(self): + """Prüft, dass nur die im Dialog gewählten Einträge in die Legende geschrieben und die Auswahl gespeichert wird.""" + ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=("kreis",))) + self.assertEqual(ergebnis["status"], "ok") + self.assertEqual(ausgabe.geschrieben["eintraege"], ["kreis"]) + self.assertEqual(ausgabe.geschrieben["massstab"], 5000.0) + eintraege, vorbelegt = self.dialog_aufrufe[0] + self.assertEqual([e["gruppe"] for e in eintraege], ["Verkehr", "Vegetation"]) + self.assertEqual(vorbelegt, {"kreis", "wald"}) + self.assertEqual(json.loads(get_variable(AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"]) + + def test_abbruch_im_dialog_aendert_nichts(self): + """Prüft, dass ein Abbruch im Auswahldialog die Legende nicht verändert und keine Auswahl speichert.""" + ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=None)) + self.assertEqual(ergebnis["status"], "abgebrochen") + self.assertIsNone(ausgabe.geschrieben) + self.assertFalse(get_variable(AUSWAHL_VAR, scope="project")) + + def test_rueckfall_auf_druckbereich_wird_gemeldet(self): + """Prüft, dass ohne Verfahrensgebiet auf den Druckbereich zurückgefallen und das gemeldet wird.""" + ergebnis, ausgabe = self._starte(self._legende(), genutzt=AUSWERTEBEREICH_DRUCKBEREICH) + self.assertEqual(ergebnis["status"], "ok") + self.assertEqual(ausgabe.geschrieben["bereich"], AUSWERTEBEREICH_DRUCKBEREICH) + self.assertTrue(any("Druckbereich" in m for m in self.meldungen)) + + def test_ohne_objekte_keine_legende(self): + """Prüft, dass ohne Basemap-Objekte im Auswertebereich weder Dialog noch Legende entstehen.""" + ergebnis, ausgabe = self._starte(self._legende(), eintraege=[]) + self.assertEqual(ergebnis["status"], "keine_eintraege") + self.assertEqual(self.dialog_aufrufe, []) + self.assertIsNone(ausgabe.geschrieben) + + def test_viele_kacheln_werden_vorher_bestaetigt(self): + """Prüft, dass vor einer Auswertung mit sehr vielen Kacheln nachgefragt wird und eine Ablehnung die Auswertung abbricht.""" + legende = BasemapLegende(project=object(), katalog=KATALOG, dialog=lambda *a: [], + fortschritt_fabrik=lambda *a: self.fail("keine Auswertung erwartet"), + bestaetigen=lambda *a: False, melden=lambda *a: None) + with patch("sn_basis.modules.basemap_legende.anzahl_kacheln", return_value=bl.KACHELN_NACHFRAGEN_AB): + self.assertIsNone(legende.ermittle_eintraege(object(), object(), 5000.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_basemap_legende_layout.py b/tests/test_basemap_legende_layout.py new file mode 100644 index 0000000..33f2af2 --- /dev/null +++ b/tests/test_basemap_legende_layout.py @@ -0,0 +1,226 @@ +""" +Tests für modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout (Basis #88). + +Layout, Legende und Projekt sind Fakes. Aussehen der Symbole und Platzierung im echten Layout +sind im QGIS-Test geprüft (siehe Pull Request). +""" +import json +import unittest +from unittest.mock import patch + +from sn_basis.modules import basemap_legende_layout as bll +from sn_basis.modules.basemap_legende import LegendenEintrag + + +def _wert(objekt, name): + """Liest x/y/width/height – im Mock-Modus Attribute, in QGIS Methoden.""" + wert = getattr(objekt, name) + return wert() if callable(wert) else wert + + +class _Rechteck: + def __init__(self, x, y, b, h): self.w = (x, y, b, h) + def x(self): return self.w[0] + def y(self): return self.w[1] + def width(self): return self.w[2] + def height(self): return self.w[3] + + +class _Element: + def __init__(self, x=0.0, y=0.0, b=0.0, h=0.0): + self.rechteck = _Rechteck(x, y, b, h) + + def sceneBoundingRect(self): return self.rechteck + + +class _Seite: + def __init__(self, breite): self._breite = breite + def pageSize(self): return type("S", (), {"width": lambda s: self._breite})() + + +class _Layout: + def __init__(self, elemente=None, breite=841.0, name="Plan"): + self.elemente = dict(elemente or {}) + self.eigenschaften = {} + self.entfernt = [] + self._breite, self._name = breite, name + + def itemById(self, element_id): return self.elemente.get(element_id) + def items(self): return list(self.elemente.values()) + def setCustomProperty(self, k, v): self.eigenschaften[k] = v + def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard) + def removeLayoutItem(self, element): self.entfernt.append(element) + def name(self): return self._name + def pageCollection(self): return type("P", (), {"page": lambda s, i: _Seite(self._breite)})() + + +class _Legende(_Element): + """Legende, deren Platzbedarf je Spaltenzahl vorgegeben ist.""" + + def __init__(self): + super().__init__() + self.spalten = 1 + self.bewegt = None + self.groesse = None + + def setColumnCount(self, n): self.spalten = n + def setSplitLayer(self, wert): pass + def setEqualColumnWidth(self, wert): pass + def setReferencePoint(self, punkt): pass + def attemptMove(self, punkt): self.bewegt = punkt + def attemptResize(self, groesse): self.groesse = groesse + + +class TestKennzeichenUndKarte(unittest.TestCase): + + def test_layout_kennzeichen_setzen_und_erkennen(self): + """Prüft, dass ein markiertes Layout als Nutzer der Basemap-Legende erkannt und in der Projektliste geführt wird.""" + markiert, normal = _Layout(name="§41 Plan"), _Layout(name="Baufreigabe") + bll.markiere_layout(markiert) + self.assertTrue(bll.nutzt_basemap_legende(markiert)) + self.assertFalse(bll.nutzt_basemap_legende(normal)) + projekt = type("P", (), {"layoutManager": lambda s: type("M", (), { + "printLayouts": lambda s: [markiert, normal]})()})() + self.assertEqual(bll.layouts_mit_basemap_legende(projekt), ["§41 Plan"]) + + def test_hauptkarte_ueber_id(self): + """Prüft, dass die Karte mit der ID „Hauptkarte“ gefunden wird.""" + karte = _Element() + self.assertIs(bll.hauptkarte(_Layout({"Hauptkarte": karte})), karte) + self.assertIsNone(bll.hauptkarte(_Layout())) + + +class TestSymbolwahl(unittest.TestCase): + + def test_symbolnamen_aus_aktiven_symbol_stilen_in_renderer_reihenfolge(self): + """Prüft, dass die Symbol-Styles des Katalogs in Zeichenreihenfolge genommen und inaktive übergangen werden.""" + eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr", "Kontur_Kreisstr"], + treffer=["Bruecke_Kreisstr_Decker"]) + aktive = {"Kontur_Kreisstr": 3, "Decker_Kreisstr": 7, "Bruecke_Kreisstr_Decker": 9} + self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Kontur_Kreisstr", "Decker_Kreisstr"]) + + def test_ohne_aktive_symbol_stile_erster_getroffener_style(self): + """Prüft, dass ohne aktiven Symbol-Style der erste tatsächlich getroffene Style das Legendensymbol liefert.""" + eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr"], + treffer=["Bruecke_Kreisstr_Decker", "Tunnel_Kreisstr"]) + aktive = {"Bruecke_Kreisstr_Decker": 9, "Tunnel_Kreisstr": 2} + self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Bruecke_Kreisstr_Decker"]) + + +class _Symbol: + def __init__(self, typ): self._typ = typ + def type(self): return self._typ + + +class _Projekt: + def __init__(self): + self.layer = {} + + def addMapLayer(self, layer, in_baum=True): + layer._in_baum = in_baum + self.layer[layer.id()] = layer + + def mapLayer(self, layer_id): return self.layer.get(layer_id) + def removeMapLayer(self, layer_id): self.layer.pop(layer_id, None) + + +class _Speicherlayer: + zaehler = 0 + + def __init__(self, uri, name, provider): + _Speicherlayer.zaehler += 1 + self.uri, self._name, self._id = uri, name, f"hl{_Speicherlayer.zaehler}" + self.eigenschaften = {} + self.renderer = None + + def id(self): return self._id + def setRenderer(self, renderer): self.renderer = renderer + def setCustomProperty(self, k, v): self.eigenschaften[k] = v + + +class TestHilfslayer(unittest.TestCase): + + @patch("sn_basis.modules.basemap_legende_layout.QgsVectorLayer", _Speicherlayer) + def test_je_gruppe_und_geometrietyp_ein_leerer_hilfslayer(self): + """Prüft, dass je Gruppe und Geometrietyp ein Hilfslayer außerhalb des Layerbaums entsteht, Flächen vor Linien, ohne Rückfrage beim Schließen.""" + eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []), + LegendenEintrag("platz", "Platz", "Verkehr", []), + LegendenEintrag("wald", "Wald", "Vegetation", []), + LegendenEintrag("ohne", "Ohne Symbol", "Vegetation", [])] + symbole = {"kreis": _Symbol(1), "platz": _Symbol(2), "wald": _Symbol(2)} + projekt = _Projekt() + + gruppen = bll.erzeuge_hilfslayer(projekt, "Plan", "EPSG:25833", eintraege, symbole) + + self.assertEqual([g for g, _ in gruppen], ["Verkehr", "Vegetation"]) + verkehr = gruppen[0][1] + self.assertTrue(verkehr[0].uri.startswith("Polygon?crs=EPSG:25833")) + self.assertTrue(verkehr[1].uri.startswith("LineString?")) + self.assertEqual([k.label() for k in verkehr[1].renderer.categories()], ["Kreisstraße"]) + self.assertEqual(len(gruppen[1][1]), 1) + for layer in projekt.layer.values(): + self.assertFalse(layer._in_baum) + self.assertEqual(layer.eigenschaften["skipMemoryLayersCheck"], 1) + self.assertEqual(layer.eigenschaften[bll.HILFSLAYER_KENNUNG], "Plan") + + def test_alte_hilfslayer_werden_entfernt(self): + """Prüft, dass die Hilfslayer einer früheren Basemap-Legende des Layouts aus dem Projekt entfernt werden.""" + projekt = _Projekt() + alt = _Speicherlayer("Point", "alt", "memory") + projekt.addMapLayer(alt, False) + layout = _Layout() + layout.setCustomProperty(bll.LAYOUT_HILFSLAYER, f"{alt.id()},gibt-es-nicht") + bll.entferne_hilfslayer(projekt, layout) + self.assertEqual(projekt.layer, {}) + + def test_ohne_eintraege_wird_vorhandene_basemap_legende_entfernt(self): + """Prüft, dass ohne gewählte Einträge eine vorhandene Basemap-Legende entfernt und die Auswahl am Layout vermerkt wird.""" + vorhandene = _Legende() + layout = _Layout({bll.LEGENDE_ID: vorhandene}) + hinweis = bll.schreibe_legende(layout, _Element(), [], {}, _Projekt(), bereich="druckbereich", massstab=2500) + self.assertEqual(hinweis, "") + self.assertEqual(layout.entfernt, [vorhandene]) + self.assertEqual(layout.eigenschaften[bll.LAYOUT_BEREICH], "druckbereich") + self.assertEqual(json.loads(layout.eigenschaften[bll.LAYOUT_EINTRAEGE]), []) + self.assertTrue(bll.nutzt_basemap_legende(layout)) + + +class TestPlatzierung(unittest.TestCase): + + def _layout(self, breite=841.0): + return _Layout({ + bll.HAUPTLEGENDE_ID: _Element(380, 10, 180, 50), + bll.QUELLENANGABE_ID: _Element(380, 198, 180, 20), + bll.HAUPTKARTE_ID: _Element(10, 10, 360, 310), + }, breite=breite) + + def _platziere(self, layout, bedarf): + legende = _Legende() + with patch.object(bll, "mindestgroesse", side_effect=lambda l, lay, n: bedarf[n]): + hinweis = bll.platziere(layout, legende) + return legende, hinweis + + def test_einspaltig_ueber_der_quellenangabe(self): + """Prüft, dass die Basemap-Legende einspaltig unten an der Quellenangabe verankert wird, wenn der Platz reicht.""" + legende, hinweis = self._platziere(self._layout(), {1: (100.0, 120.0)}) + self.assertEqual(hinweis, "") + self.assertEqual(legende.spalten, 1) + self.assertEqual((_wert(legende.bewegt, "x"), _wert(legende.bewegt, "y")), (380, 198 - bll.ABSTAND_MM)) + self.assertEqual((_wert(legende.groesse, "width"), _wert(legende.groesse, "height")), (100.0, 120.0)) + + def test_mehrspaltig_wenn_hoehe_nicht_reicht(self): + """Prüft, dass die Legende zwei- oder dreispaltig wird, wenn sie einspaltig nicht zwischen Hauptlegende und Quellenangabe passt.""" + legende, hinweis = self._platziere(self._layout(), {1: (100.0, 300.0), 2: (200.0, 150.0), 3: (300.0, 100.0)}) + self.assertEqual(hinweis, "") + self.assertEqual(legende.spalten, 3) + + def test_nie_breiter_als_der_seitenrand_mit_hinweis(self): + """Prüft, dass keine Spaltenzahl gewählt wird, die über den Seitenrand ragt, und dann ein Hinweis kommt.""" + legende, hinweis = self._platziere(self._layout(breite=600.0), + {1: (100.0, 300.0), 2: (200.0, 160.0), 3: (300.0, 100.0)}) + self.assertEqual(legende.spalten, 2) + self.assertIn("mehr Platz", hinweis) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_basemap_stilvorlagen.py b/tests/test_basemap_stilvorlagen.py new file mode 100644 index 0000000..2c5c47c --- /dev/null +++ b/tests/test_basemap_stilvorlagen.py @@ -0,0 +1,128 @@ +""" +Tests für modules/basemap_stilvorlagen.py – lokale Stilvorlagen des Basemap-Layers (Basis #88). + +Gespeichert wird in einem temporären Ordner; der Layer schreibt statt einer echten QML-Datei +eine kleine Textdatei. Laden und Speichern am echten Vektorkachel-Layer ist im QGIS-Test geprüft. +""" +import os +import shutil +import tempfile +import unittest + +from sn_basis.modules.basemap_stilvorlagen import STANDARD_SCHLUESSEL, BasemapStilvorlagen + + +class _Einstellungen: + """Ersatz für QSettings – die Tests dürfen keine echten Benutzereinstellungen verändern.""" + + def __init__(self): self.daten = {} + def value(self, k, standard=""): return self.daten.get(k, standard) + def setValue(self, k, v): self.daten[k] = v + def remove(self, k): self.daten.pop(k, None) + + +class _Stil: + def __init__(self, name): self._name = name + def styleName(self): return self._name + + +class _Layer: + def __init__(self, stile=("Kontur_Kreisstr", "Decker_Kreisstr"), speichern_ok=True): + self.stile = list(stile) + self.geladen = None + self.neu_gezeichnet = False + self._speichern_ok = speichern_ok + + def name(self): return "Basemap.de - Farbe" + def renderer(self): return type("R", (), {"styles": lambda s: [_Stil(n) for n in self.stile]})() + + def saveNamedStyle(self, pfad, kategorien): + if not self._speichern_ok: + return "kein Schreibrecht", False + with open(pfad, "w", encoding="utf-8") as datei: + datei.write("") + return "", True + + def loadNamedStyle(self, pfad, kategorien): + self.geladen = pfad + return "", True + + def triggerRepaint(self): self.neu_gezeichnet = True + + +class TestStilvorlagen(unittest.TestCase): + + def setUp(self): + self.ordner = tempfile.mkdtemp() + self.einstellungen = _Einstellungen() + self.vorlagen = BasemapStilvorlagen(os.path.join(self.ordner, "stile"), self.einstellungen) + + def tearDown(self): + shutil.rmtree(self.ordner, ignore_errors=True) + + def test_speichern_und_auflisten(self): + """Prüft, dass eine gespeicherte Vorlage mit Anzeigenamen, QML-Datei und Begleitdatei aufgelistet wird.""" + self.vorlagen.speichern(_Layer(), "Ohne Hausnummern", {"ausgeblendet": ["wald"], "eingeblendet": []}) + liste = self.vorlagen.liste() + self.assertEqual([v.anzeigename for v in liste], ["Ohne Hausnummern"]) + self.assertEqual(liste[0].name, "Ohne_Hausnummern") + self.assertTrue(os.path.exists(liste[0].pfad)) + self.assertEqual(self.vorlagen.legendenauswahl("Ohne_Hausnummern")["ausgeblendet"], ["wald"]) + + def test_mehrere_varianten_alphabetisch(self): + """Prüft, dass mehrere Varianten nebeneinander bestehen und alphabetisch nach Anzeigename sortiert werden.""" + for name in ("Zeichnung", "Abstimmung", "mit Gebäuden"): + self.vorlagen.speichern(_Layer(), name) + self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Abstimmung", "mit Gebäuden", "Zeichnung"]) + + def test_leerer_name_oder_fehler_beim_schreiben(self): + """Prüft, dass ein leerer Name und ein Schreibfehler von QGIS als ValueError gemeldet werden.""" + with self.assertRaises(ValueError): + self.vorlagen.speichern(_Layer(), " ") + with self.assertRaises(ValueError): + self.vorlagen.speichern(_Layer(speichern_ok=False), "Test") + + def test_laden_setzt_stil_und_zeichnet_neu(self): + """Prüft, dass das Laden die QML-Datei der Vorlage in den Layer lädt und neu zeichnen lässt.""" + self.vorlagen.speichern(_Layer(), "Standard LNO") + layer = _Layer() + self.vorlagen.laden(layer, "Standard_LNO") + self.assertTrue(layer.geladen.endswith("Standard_LNO.qml")) + self.assertTrue(layer.neu_gezeichnet) + with self.assertRaises(ValueError): + self.vorlagen.laden(layer, "gibt_es_nicht") + + def test_passung_meldet_fremde_styles(self): + """Prüft, dass Style-Namen der Vorlage gemeldet werden, die der Ziel-Layer nicht kennt.""" + self.vorlagen.speichern(_Layer(stile=("A", "B", "C")), "Grau") + self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A",)), "Grau"), ["B", "C"]) + self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A", "B", "C")), "Grau"), []) + + def test_standard_setzen_und_beim_loeschen_entfernen(self): + """Prüft, dass eine Vorlage als Standard markiert wird und die Markierung beim Löschen der Vorlage entfällt.""" + self.vorlagen.speichern(_Layer(), "Standard LNO") + self.vorlagen.setze_standard("Standard_LNO") + self.assertTrue(self.vorlagen.liste()[0].standard) + self.vorlagen.loeschen("Standard_LNO") + self.assertEqual(self.vorlagen.liste(), []) + self.assertEqual(self.vorlagen.standard(), "") + self.assertNotIn(STANDARD_SCHLUESSEL, self.einstellungen.daten) + with self.assertRaises(ValueError): + self.vorlagen.setze_standard("Standard_LNO") + + def test_fehlende_begleitdatei_wird_toleriert(self): + """Prüft, dass eine QML-Datei ohne Begleitdatei mit ihrem Dateinamen aufgelistet wird.""" + os.makedirs(self.vorlagen.ordner, exist_ok=True) + with open(os.path.join(self.vorlagen.ordner, "Alt.qml"), "w", encoding="utf-8") as datei: + datei.write("") + self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Alt"]) + self.assertIsNone(self.vorlagen.legendenauswahl("Alt")) + self.assertEqual(self.vorlagen.pruefe_passung(_Layer(), "Alt"), []) + + def test_ohne_ordner_leere_liste(self): + """Prüft, dass ohne angelegten Vorlagenordner eine leere Liste zurückkommt.""" + self.assertEqual(self.vorlagen.liste(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/tabs/print_tab.py b/ui/tabs/print_tab.py index 37ff357..fc904f5 100644 --- a/ui/tabs/print_tab.py +++ b/ui/tabs/print_tab.py @@ -22,6 +22,7 @@ from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyMode from sn_basis.functions.qgisui_wrapper import iface, QgsMapLayerComboBox from sn_basis.functions.variable_wrapper import get_variable, set_variable from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.ui.widgets.basemap_bereich import BasemapBereich from sn_basis.modules.DataGrabber import DataGrabber from sn_basis.modules.print_logic import ( PrintLogic, @@ -257,6 +258,10 @@ class PrintTab(QWidget): self._atlas_content = atlas_content main_layout.addWidget(atlas_content) + # Basemap-Bereich (einklappbar): Basemap-Legende und Stilvorlagen (Basis #88) + self._basemap_bereich = BasemapBereich(self) + main_layout.addWidget(self._basemap_bereich) + btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self) btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen) self._btn_vorlage_erstellen = btn_vorlage_erstellen @@ -445,6 +450,13 @@ class PrintTab(QWidget): formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False, druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET, ) + self._basemap_bereich_aktualisieren() + + def _basemap_bereich_aktualisieren(self) -> None: + """Liest den Basemap-Bereich neu ein (neue Vorlage, Projektwechsel).""" + bereich = getattr(self, "_basemap_bereich", None) + if bereich is not None: + bereich.aktualisieren() def _disconnect_project_signals(self) -> None: try: @@ -494,6 +506,7 @@ class PrintTab(QWidget): self._theme_signal_connected = False self._connect_theme_collection_signals() self._restore_state() + self._basemap_bereich_aktualisieren() def _toggle_atlas(self, checked: bool) -> None: if self._atlas_button: diff --git a/ui/widgets/__init__.py b/ui/widgets/__init__.py new file mode 100644 index 0000000..97791b9 --- /dev/null +++ b/ui/widgets/__init__.py @@ -0,0 +1 @@ +"""sn_basis/ui/widgets – wiederverwendbare Bereiche für Tabs.""" diff --git a/ui/widgets/basemap_bereich.py b/ui/widgets/basemap_bereich.py new file mode 100644 index 0000000..c4f1816 --- /dev/null +++ b/ui/widgets/basemap_bereich.py @@ -0,0 +1,271 @@ +""" +sn_basis/ui/widgets/basemap_bereich.py – einklappbarer Bereich „Basemap“ im Druck-Tab + +Einstellungen der Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage), +„Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des +Basemap-Layers (laden, speichern, als Standard markieren, löschen). Basis #88. + +Die Handler enthalten nur Ablauf und Rückfragen; die Logik liegt in +``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``. +""" +from __future__ import annotations + +from typing import Any, Optional + +from sn_basis.functions.dialog_wrapper import ask_text, ask_yes_no +from sn_basis.functions.message_wrapper import info, success, warning +from sn_basis.functions.qgiscore_wrapper import QgsProject +from sn_basis.functions.qt_wrapper import ( + ArrowDown, + ArrowRight, + QCheckBox, + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, + ToolButtonTextBesideIcon, +) +from sn_basis.modules import basemap_legende as legende +from sn_basis.modules.basemap_legende_layout import layouts_mit_basemap_legende +from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen + +TITEL = "Basemap" +KEIN_LAYER_TEXT = "Kein Basemap-Layer im Projekt" + + +class BasemapBereich(QWidget): + """Einklappbarer Basemap-Bereich; standardmäßig eingeklappt.""" + + def __init__(self, parent: Optional[Any] = None, project: Any | None = None, + stilvorlagen: BasemapStilvorlagen | None = None) -> None: + super().__init__(parent) + self._project = project + self._stilvorlagen = stilvorlagen + self._build_ui() + self.aktualisieren() + + # --- Aufbau --------------------------------------------------------- + + def _build_ui(self) -> None: + """von Test abgesehen, weil reiner Widget-Aufbau; im QGIS-Test geprüft""" + haupt = QVBoxLayout() + haupt.setSpacing(4) + haupt.setContentsMargins(0, 0, 0, 0) + + knopf = QToolButton() + knopf.setText(TITEL) + knopf.setCheckable(True) + knopf.setChecked(False) + knopf.setToolButtonStyle(ToolButtonTextBesideIcon) + knopf.setArrowType(ArrowRight) + knopf.setStyleSheet("font-weight: bold; margin-top: 6px;") + knopf.toggled.connect(self._umschalten) + self._knopf = knopf + haupt.addWidget(knopf) + + inhalt = QWidget() + spalte = QVBoxLayout() + spalte.setSpacing(4) + spalte.setContentsMargins(10, 4, 4, 4) + + self._status = QLabel(KEIN_LAYER_TEXT) + self._status.setWordWrap(True) + spalte.addWidget(self._status) + + spalte.addWidget(self._ueberschrift("Legende")) + spalte.addWidget(QLabel("Auswertebereich")) + self._bereich_combo = QComboBox(self) + for schluessel, text in legende.AUSWERTEBEREICHE.items(): + self._bereich_combo.addItem(text, schluessel) + self._bereich_combo.currentIndexChanged.connect(self._on_bereich_geaendert) + spalte.addWidget(self._bereich_combo) + + self._erzeugen_cb = QCheckBox("Basemap-Legende beim Erstellen der Vorlage erzeugen (§ 38, § 41)", self) + self._erzeugen_cb.toggled.connect(self._on_erzeugen_geaendert) + spalte.addWidget(self._erzeugen_cb) + + spalte.addWidget(QLabel("Vorlage")) + self._layout_combo = QComboBox(self) + spalte.addWidget(self._layout_combo) + self._aktualisieren_btn = QPushButton("Basemap-Legende aktualisieren", self) + self._aktualisieren_btn.clicked.connect(self._on_legende_aktualisieren) + spalte.addWidget(self._aktualisieren_btn) + + spalte.addWidget(self._ueberschrift("Stilvorlage")) + self._stil_combo = QComboBox(self) + spalte.addWidget(self._stil_combo) + zeile = QHBoxLayout() + for text, handler in ( + ("Laden", self._on_stil_laden), + ("Speichern unter …", self._on_stil_speichern), + ("Als Standard", self._on_stil_standard), + ("Löschen", self._on_stil_loeschen), + ): + knopf_stil = QPushButton(text, self) + knopf_stil.clicked.connect(lambda *_, h=handler: h()) + zeile.addWidget(knopf_stil) + spalte.addLayout(zeile) + + inhalt.setLayout(spalte) + inhalt.setVisible(False) + self._inhalt = inhalt + haupt.addWidget(inhalt) + self.setLayout(haupt) + + @staticmethod + def _ueberschrift(text: str) -> Any: + beschriftung = QLabel(text) + beschriftung.setStyleSheet("font-weight: bold; margin-top: 4px;") + return beschriftung + + # --- Zustand -------------------------------------------------------- + + def _projekt(self) -> Any: + return getattr(self, "_project", None) or QgsProject.instance() + + def _vorlagen(self) -> BasemapStilvorlagen: + if getattr(self, "_stilvorlagen", None) is None: + self._stilvorlagen = BasemapStilvorlagen() + return self._stilvorlagen + + def basemap_layer(self) -> Any | None: + """Erster Basemap-Layer des Projekts (sichtbar oder nicht).""" + try: + layer = list(self._projekt().mapLayers().values()) + except Exception: + return None + gefunden = legende.finde_basemap_layer(layer) + return gefunden[0] if gefunden else None + + def _umschalten(self, offen: bool) -> None: + self._knopf.setArrowType(ArrowDown if offen else ArrowRight) + self._inhalt.setVisible(offen) + if offen: + self.aktualisieren() + + def aktualisieren(self) -> None: + """Liest Projektvariablen, Layouts und Stilvorlagen neu ein (z. B. nach Projektwechsel).""" + layer = self.basemap_layer() + self._status.setText(f"Basemap-Layer: {layer.name()}" if layer is not None else KEIN_LAYER_TEXT) + + bereich = legende.lies_auswertebereich() + self._bereich_combo.blockSignals(True) + self._bereich_combo.setCurrentIndex(max(0, self._bereich_combo.findData(bereich))) + self._bereich_combo.blockSignals(False) + self._erzeugen_cb.blockSignals(True) + self._erzeugen_cb.setChecked(legende.legende_erzeugen_aktiv()) + self._erzeugen_cb.blockSignals(False) + + self._layout_combo.clear() + self._layout_combo.addItems(layouts_mit_basemap_legende(self._projekt())) + self._aktualisieren_btn.setEnabled(layer is not None and self._layout_combo.count() > 0) + + self._stil_combo.clear() + for vorlage in self._vorlagen().liste(): + self._stil_combo.addItem(f"{vorlage.anzeigename}{' ★' if vorlage.standard else ''}", vorlage.name) + standard = self._vorlagen().standard() + if standard: + self._stil_combo.setCurrentIndex(max(0, self._stil_combo.findData(standard))) + + # --- Handler: Legende ----------------------------------------------- + + def _on_bereich_geaendert(self, *_: Any) -> None: + bereich = self._bereich_combo.currentData() + if bereich in legende.AUSWERTEBEREICHE: + legende.setze_auswertebereich(bereich) + + def _on_erzeugen_geaendert(self, aktiv: bool) -> None: + legende.setze_legende_erzeugen(bool(aktiv)) + + def _on_legende_aktualisieren(self) -> None: + name = self._layout_combo.currentText() + layout = self._projekt().layoutManager().layoutByName(name) if name else None + if layout is None: + warning(TITEL, "Bitte eine Vorlage mit Basemap-Legende wählen.") + return + try: + ergebnis = legende.BasemapLegende(self._projekt()).aktualisiere(layout) + except Exception as fehler: + warning(TITEL, f"Die Basemap-Legende konnte nicht aktualisiert werden: {fehler}") + return + if ergebnis["status"] == "ok": + success(TITEL, ergebnis["meldung"]) + elif ergebnis["status"] != "abgebrochen": + info(TITEL, ergebnis["meldung"]) + + # --- Handler: Stilvorlagen ------------------------------------------ + + def _gewaehlte_vorlage(self) -> str: + return str(self._stil_combo.currentData() or "") + + def _on_stil_laden(self) -> None: + layer = self.basemap_layer() + name = self._gewaehlte_vorlage() + if layer is None or not name: + warning(TITEL, "Kein Basemap-Layer oder keine Stilvorlage gewählt.") + return + if not ask_yes_no("Stilvorlage laden", + f"Darstellung von „{layer.name()}“ durch die Stilvorlage ersetzen?", default=False): + return + unbekannt = self._vorlagen().pruefe_passung(layer, name) + if unbekannt and not ask_yes_no( + "Stilvorlage laden", + f"{len(unbekannt)} Styles der Vorlage gibt es in diesem Layer nicht – die Vorlage stammt " + "vermutlich von einer anderen Basemap-Variante. Trotzdem laden?", + default=False, + ): + return + try: + self._vorlagen().laden(layer, name) + except ValueError as fehler: + warning(TITEL, str(fehler)) + return + auswahl = self._vorlagen().legendenauswahl(name) + if auswahl is not None: + import json + from sn_basis.functions.variable_wrapper import set_variable + set_variable(legende.AUSWAHL_VAR, json.dumps(auswahl), scope="project") + success(TITEL, "Stilvorlage geladen.") + + def _on_stil_speichern(self) -> None: + layer = self.basemap_layer() + if layer is None: + warning(TITEL, KEIN_LAYER_TEXT) + return + anzeigename, ok = ask_text("Stilvorlage speichern", "Name der Stilvorlage:", + default_text=self._stil_combo.currentText().replace(" ★", "")) + if not ok or not anzeigename.strip(): + return + if self._vorlagen().vorhanden(anzeigename) and not ask_yes_no( + "Stilvorlage speichern", f"Die Stilvorlage „{anzeigename}“ gibt es schon. Ersetzen?", default=False + ): + return + try: + self._vorlagen().speichern(layer, anzeigename, legende.lies_auswahl()) + except ValueError as fehler: + warning(TITEL, str(fehler)) + return + self.aktualisieren() + success(TITEL, f"Stilvorlage „{anzeigename.strip()}“ gespeichert.") + + def _on_stil_standard(self) -> None: + name = self._gewaehlte_vorlage() + if not name: + return + try: + self._vorlagen().setze_standard(name) + except ValueError as fehler: + warning(TITEL, str(fehler)) + return + self.aktualisieren() + + def _on_stil_loeschen(self) -> None: + name = self._gewaehlte_vorlage() + if not name or not ask_yes_no("Stilvorlage löschen", + f"Stilvorlage „{self._stil_combo.currentText()}“ löschen?", default=False): + return + self._vorlagen().loeschen(name) + self.aktualisieren()