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:
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user