Files
API_Karte_QGISDemo/vln_karten/zeichen_werkzeug.py
T
KAIandClaude Opus 5.5 a56bf7c397 Fassung 0.7.0: Landkreis-Linkliste, oFB liest nur, Untergruppen, 403 meldet nicht ab
- Auswahl über GET /tgen/auswahl (Manager seit 06.10.2026): oben die eigenen Landkreise
  („… — Linkliste des Landkreises“), dann die Verfahren; als oFB die Verfahren des Landkreises
  mit „(nur lesen)“. Älterer Server ohne die Route: Rückfall auf /tgen.
- Landkreis gewählt: „Liste holen“ lädt die Linkliste des ganzen Landkreises; „Layer laden“
  erklärt, dass es für einen Landkreis keine Verfahrenslayer gibt.
- 401 und 403 getrennt: nur 401 (AuthError) meldet ab, 403 (KeinZugriff) zeigt eine Meldung.
  Bis 0.6.0 verwarf jedes 403 den gespeicherten API-Key.
- OGC-Sammlungen mit vln:schreibbar: false werden schreibgeschützt geladen; Meldungen für Stil
  und Verkehrszeichen nennen „nur lesen“ als Grund.
- Linkliste: Gruppe › Untergruppe › Layer im Ebenenbaum (linkliste.zielgruppe), Untergruppe
  auch in der Dienstliste.
- README: Abschnitt „Geodienste aus der Linkliste“; Tests ohne Netz:
  tools/headless_linkliste_test.py (neu), tools/headless_panel_test.py erweitert — beide grün
  mit QGIS 4.2 offscreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:44:36 +02:00

326 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""Verkehrszeichen setzen — Palette und Kartenwerkzeuge.
Im Browser wählt man links ein Schild aus und setzt es mit zwei Klicks: der erste bestimmt
den Standort, der zweite die Blickrichtung. Dieselbe Bedienung gibt es hier für QGIS, damit
niemand Codes in eine Attributtabelle tippen muss (Erik, 21.09.2026 — Nutzerkreis sind
TG-Mitarbeiter und der Landkreis).
Drei Teile:
* :class:`ZeichenPalette` — Suchfeld, Kategorie, Kacheln aus den lokal abgelegten Bildern
(:mod:`vln_karten.zeichen_katalog`).
* :class:`SchildSetzen` — zwei Klicks, legt das Feature im aktiven Layer an.
* :class:`ZusatzAnhaengen` — ein Klick auf ein vorhandenes Schild ergänzt sein ``zusatz``.
Die Rechenregeln sind Zwillinge der Browser-Fassung (``servicetermin/web_assets/vz_geom.js``:
``vzRichtungGrad``, ``vzNormGrad``, ``vzZusatzErgaenzen``) — dieselben Werte, hier in Python.
"""
import math
from qgis.core import QgsFeature, QgsGeometry, QgsPointXY, QgsProject, QgsWkbTypes
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import QSize, Qt, pyqtSignal
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import (QComboBox, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QVBoxLayout, QWidget)
from . import zeichen_katalog as zk
#: Wie viele Kacheln die Palette höchstens zeigt — mehr macht sie unübersichtlich und
#: zwänge uns, Hunderte Vorschaubilder auf einmal zu laden (Browser: VZ_PALETTE_MAX).
PALETTE_MAX = 96
#: Höchstens zwei Zusatzzeichen je Schild (Browser: VZ_MAX_ZUSATZ).
MAX_ZUSATZ = 2
#: Kürzer als das gilt der zweite Klick als Wackeln, nicht als Richtung.
MIN_RICHTUNG_M = 0.5
# ----------------------------------------------------------------------------- Rechnen
def norm_grad(g):
"""Gradzahl auf 0 … 359,999 bringen (auch negativ, Text, ``None``)."""
try:
g = float(g)
except (TypeError, ValueError):
return 0.0
if math.isinf(g) or math.isnan(g):
return 0.0
return ((g % 360) + 360) % 360
def richtung_grad(p0, p1):
"""Blickrichtung aus zwei Punkten in Grad, im Uhrzeigersinn ab Nord (0 = Nord, 90 = Ost).
Punkte in Metern. Liegen sie aufeinander (Doppelklick), ist die Richtung 0.
"""
dx, dy = p1[0] - p0[0], p1[1] - p0[1]
if math.hypot(dx, dy) < 1e-9:
return 0.0
return round(norm_grad(math.degrees(math.atan2(dx, dy))) * 10) / 10
def zusatz_ergaenzen(liste, code, hoechstens=MAX_ZUSATZ):
"""Ein Zusatzzeichen anhängen — ohne Doppel, ohne Verdrängen.
:returns: ``(neue_liste, geaendert)``
"""
aus = [str(x).strip() for x in (liste or []) if str(x or "").strip()]
code = str(code or "").strip()
if not code or code in aus or len(aus) >= hoechstens:
return aus, False
return aus + [code], True
# ----------------------------------------------------------------------------- Palette
class ZeichenPalette(QWidget):
"""Kacheln zum Auswählen eines Zeichens; meldet den gewählten Code."""
gewaehlt = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._daten = {}
self._client = None
lage = QVBoxLayout(self)
lage.setContentsMargins(0, 0, 0, 0)
lage.setSpacing(4)
self.suche = QLineEdit()
self.suche.setPlaceholderText("Schild suchen (Nummer oder Name) …")
self.suche.setClearButtonEnabled(True)
self.suche.textChanged.connect(self.auffrischen)
self.kategorie = QComboBox()
self.kategorie.currentIndexChanged.connect(self.auffrischen)
self.gitter = QListWidget()
self.gitter.setViewMode(QListWidget.ViewMode.IconMode)
self.gitter.setIconSize(QSize(40, 40))
self.gitter.setGridSize(QSize(52, 56))
self.gitter.setResizeMode(QListWidget.ResizeMode.Adjust)
self.gitter.setMovement(QListWidget.Movement.Static)
self.gitter.setMinimumHeight(120)
self.gitter.itemClicked.connect(self._geklickt)
self.zeile = QLabel("kein Zeichen gewählt")
self.zeile.setWordWrap(True)
lage.addWidget(self.suche)
lage.addWidget(self.kategorie)
lage.addWidget(self.gitter)
lage.addWidget(self.zeile)
# ------------------------------------------------------------------
def setze_katalog(self, daten, client=None):
"""Katalog übernehmen und die Kategorien füllen."""
self._daten = daten or {}
self._client = client
self.kategorie.blockSignals(True)
self.kategorie.clear()
self.kategorie.addItem("häufig verwendet", "haeufig")
self.kategorie.addItem("alle Zeichen", "")
for k in self._daten.get("kategorien", []):
self.kategorie.addItem(str(k.get("name") or k.get("id")), str(k.get("id")))
self.kategorie.blockSignals(False)
self.auffrischen()
def treffer(self):
"""Die Zeichen, die Suche und Kategorie überstehen (höchstens ``PALETTE_MAX``)."""
text = self.suche.text().strip().lower()
kat = self.kategorie.currentData()
aus = []
for z in self._daten.get("zeichen", []):
if kat == "haeufig" and not z.get("haeufig") and not text:
continue
if kat and kat != "haeufig" and str(z.get("kategorie")) != kat:
continue
if text and text not in str(z.get("suche") or "").lower():
continue
aus.append(z)
if len(aus) >= PALETTE_MAX:
break
return aus
def auffrischen(self):
"""Kacheln neu aufbauen; fehlende Vorschaubilder werden dabei nachgeladen."""
treffer = self.treffer()
if self._client is not None and treffer:
try:
zk.sichere_pngs(self._client, [z["code"] for z in treffer])
except Exception: # noqa: BLE001
pass # ohne Bild bleibt der Code
self.gitter.clear()
for z in treffer:
eintrag = QListWidgetItem(str(z.get("code")))
eintrag.setToolTip("%s — %s" % (z.get("code"), z.get("name")))
eintrag.setData(Qt.ItemDataRole.UserRole, z)
pfad = zk.png_pfad(z.get("code"))
symbol = QIcon(pfad)
if not symbol.isNull():
eintrag.setIcon(symbol)
eintrag.setText("")
self.gitter.addItem(eintrag)
def _geklickt(self, eintrag):
z = eintrag.data(Qt.ItemDataRole.UserRole) or {}
self.zeile.setText("gewählt: %s — %s" % (z.get("code"), z.get("name")))
self.gewaehlt.emit(str(z.get("code") or ""))
def aktueller_eintrag(self):
eintrag = self.gitter.currentItem()
return (eintrag.data(Qt.ItemDataRole.UserRole) or {}) if eintrag else {}
# ------------------------------------------------------------------------- Kartenwerkzeuge
def _punkt_layer(iface):
"""Der aktive Layer, wenn er Punkte hält und ein Feld ``vz`` hat — sonst ``None``."""
layer = iface.activeLayer()
if layer is None or not hasattr(layer, "fields"):
return None
if QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.GeometryType.PointGeometry:
return None
return layer if "vz" in [f.name() for f in layer.fields()] else None
class SchildSetzen(QgsMapTool):
"""Zwei Klicks: Standort, dann Blickrichtung — wie im Browser.
Der zweite Klick landet nicht in der Karte; er dreht nur das Schild. Liegt er auf dem
ersten (Doppelklick), bleibt die Drehung 0.
"""
def __init__(self, canvas, iface, code_holen, melden=None, blatt_holen=None):
super().__init__(canvas)
self.iface = iface
self._code_holen = code_holen
self._melden = melden or (lambda text, fehler=False: None)
self._blatt_holen = blatt_holen or (lambda: None)
self._erster = None
def canvasReleaseEvent(self, event):
punkt = self.toLayerCoordinates(self.iface.activeLayer(), event.pos()) \
if self.iface.activeLayer() else self.toMapCoordinates(event.pos())
if self._erster is None:
layer = _punkt_layer(self.iface)
if layer is None:
self._melden("Bitte zuerst einen Zeichen-Layer aktiv schalten "
"(Punkte mit Feld „vz“).", True)
return
if not self._code_holen():
self._melden("Bitte links ein Schild in der Palette wählen.", True)
return
self._erster = QgsPointXY(punkt)
self._melden("Standort gesetzt — zweiter Klick gibt die Blickrichtung.")
return
drehung = richtung_grad((self._erster.x(), self._erster.y()), (punkt.x(), punkt.y()))
if self._erster.distance(QgsPointXY(punkt)) < MIN_RICHTUNG_M:
drehung = 0.0
self._setzen(self._erster, drehung)
self._erster = None
def canvasMoveEvent(self, event):
pass
def reset(self):
self._erster = None
def _setzen(self, punkt, drehung):
layer = _punkt_layer(self.iface)
if layer is None:
return
if not layer.isEditable() and not layer.startEditing():
self._melden("Der Layer lässt sich nicht bearbeiten — bei „nur lesen“ fehlt das Schreibrecht im Verfahren.", True)
return
namen = [f.name() for f in layer.fields()]
f = QgsFeature(layer.fields())
f.setGeometry(QgsGeometry.fromPointXY(punkt))
werte = {"vz": self._code_holen(), "drehung": drehung, "typ": "verkehrszeichen",
"vz_status": "neu", "groesse": 2}
blatt = self._blatt_holen()
if "blatt_uuid" in namen:
if not blatt:
self._melden("Diese Sammlung gehört zu den Maßnahmenblättern — bitte oben "
"das Blatt wählen, an dessen Weg das Zeichen steht.", True)
return
werte["blatt_uuid"] = blatt
for name, wert in werte.items():
if name in namen:
f.setAttribute(name, wert)
if not layer.addFeature(f):
self._melden("Das Zeichen konnte nicht angelegt werden.", True)
return
layer.triggerRepaint()
self._melden("Zeichen %s gesetzt (Blickrichtung %.0f°). Mit „Änderungen speichern“ "
"geht es an den Server." % (werte["vz"], drehung))
class ZusatzAnhaengen(QgsMapTool):
"""Ein Klick auf ein vorhandenes Schild hängt das gewählte Zusatzzeichen darunter."""
def __init__(self, canvas, iface, code_holen, melden=None, radius_px=12):
super().__init__(canvas)
self.iface = iface
self._code_holen = code_holen
self._melden = melden or (lambda text, fehler=False: None)
self._radius_px = radius_px
def canvasReleaseEvent(self, event):
layer = _punkt_layer(self.iface)
if layer is None:
self._melden("Bitte zuerst einen Zeichen-Layer aktiv schalten.", True)
return
code = self._code_holen()
if not code:
self._melden("Bitte links ein Zusatzzeichen in der Palette wählen.", True)
return
punkt = self.toLayerCoordinates(layer, event.pos())
toleranz = self.canvas().mapUnitsPerPixel() * self._radius_px
treffer, abstand = None, None
for f in layer.getFeatures():
g = f.geometry()
if g is None or g.isEmpty():
continue
d = g.asPoint().distance(QgsPointXY(punkt))
if abstand is None or d < abstand:
treffer, abstand = f, d
if treffer is None or abstand > toleranz:
self._melden("Kein Schild in der Nähe des Klicks.", True)
return
namen = [fd.name() for fd in layer.fields()]
if "zusatz" not in namen:
self._melden("Diesem Layer fehlt das Feld „zusatz“.", True)
return
alt = zk._liste(treffer["zusatz"])
neu, geaendert = zusatz_ergaenzen(alt, code)
if not geaendert:
self._melden("Schon vorhanden oder schon zwei Zusatzzeichen — nichts geändert.", True)
return
if not layer.isEditable() and not layer.startEditing():
self._melden("Der Layer lässt sich nicht bearbeiten — bei „nur lesen“ fehlt das Schreibrecht im Verfahren.", True)
return
layer.changeAttributeValue(treffer.id(), layer.fields().indexOf("zusatz"),
", ".join(neu))
layer.triggerRepaint()
self._melden("Zusatzzeichen %s angehängt (jetzt %d)." % (code, len(neu)))
def blaetter_im_projekt():
"""``[(Beschriftung, uuid)]`` aller Maßnahmenblätter, die im Projekt geladen sind.
Gelesen wird aus den Blatt-Layern (Eigenschaften ``blatt_uuid``/``blatt_name``); so
braucht das Werkzeug keine eigene Abfrage, und es stehen genau die Blätter zur Wahl,
die der Nutzer ohnehin geladen hat.
"""
aus = {}
for layer in QgsProject.instance().mapLayers().values():
if not hasattr(layer, "fields"):
continue
namen = [f.name() for f in layer.fields()]
if "blatt_uuid" not in namen:
continue
for f in layer.getFeatures():
uuid_ = str(f["blatt_uuid"] or "").strip()
if not uuid_ or uuid_ in aus:
continue
name = str(f["blatt_name"] or "").strip() if "blatt_name" in namen else ""
mkz = str(f["blatt_mkz"] or "").strip() if "blatt_mkz" in namen else ""
aus[uuid_] = " · ".join(x for x in (mkz, name) if x) or uuid_[:8]
return sorted(((text, uuid_) for uuid_, text in aus.items()), key=lambda x: x[0])