Basemap-Legende: Legendenkatalog und Generator (#88)

data/basemap_legende.json ordnet die darstellenden Styles der basemap.de-Style-JSON
(bm_web_col.json) Legendeneinträgen zu: 434 Styles in 190 Einträgen, Kontur/Decker/
Brücken-/Fahrbahnvarianten je Straßenklasse zusammengefasst, Beschriftungen,
Nummernschilder und Hintergrund ausgeschlossen. modules/basemap_katalog.py erzeugt den
Katalog (nur Standardbibliothek, auch eigenständig aufrufbar) und erhält dabei die
fachliche Nachbearbeitung; erster Durchgang der Bezeichnungen ist erledigt, die
fachliche Abnahme steht aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:57:09 +02:00
co-authored by Claude Opus 5.5
parent 54f1bf8b01
commit 7408740dfe
3 changed files with 3002 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+418
View File
@@ -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())
+174
View File
@@ -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()