Fix broken map rendering, split GPS cleanup, receipt parser and design backlog
Leaflet's stylesheet was appended to document.head, so it never reached the
panel's shadow root: .leaflet-tile{position:absolute} never applied, tiles laid
out as in-flow images (~1040px inside a 190px box) and the marker pane ended up
far below the visible area. That is the real cause of the long-standing
"fragmented Leaflet rendering" finding and of the invisible vehicle pin - every
tile request had actually succeeded. The stylesheet now goes into the shadow
root and is awaited before the map is built.
Also in this round:
- Map tiles are always light (Google-Maps-style), no dark variant at night.
- Vehicle marker uses the real CI poi-car icons (poi-car-l >=34px, poi-car-s
below), with a white halo so the outline stays readable on tiles.
- Removed the obsolete combined STANDORT_TRACKER field; only the split
lat/lon sensors remain.
- Receipt upload: widened the try block so base64/save failures surface, and
the frontend call site now reports a rejected service call.
- Generic receipt parser: total detection is line-based (letter-spaced
headings, no more matching the SUMME-EUR column header, tax lines excluded),
address heuristic handles 4-digit postcodes and single-line address blocks,
and "Preis/L" matches without a spelled-out "Liter".
- Design backlog: red hairline frame on list rows (delete button bled through
at fractional row heights) and select fields now use the grey background box.
Shell 10-receipt regression suite still passes; all changes verified live in
the audi_ha_test container.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,8 +177,11 @@ Oberfläche „unbekannt"/em-dash statt eines Werts, kein Absturz:
|
||||
(`RANGE_SENSOR`) — bis zu einer neuen Datenquelle unbelegt, siehe
|
||||
`AGENTS.md` (die frühere `TommiG1/HA_VAG-EU-Data-Act`-Integration liefert
|
||||
diese nicht mehr)
|
||||
- Standort (`STANDORT_TRACKER`, ein `device_tracker`) und Batteriespannung
|
||||
(`BATTERIE_SENSOR`) — vom FMM003, z. B. `device_tracker.<gerätename>`
|
||||
- Standort (`STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`, zwei eigene
|
||||
`sensor`-Entities für Breiten-/Längengrad — flespi liefert Koordinaten so,
|
||||
nicht als `latitude`/`longitude`-Attribute eines `device_tracker`) und
|
||||
Batteriespannung (`BATTERIE_SENSOR`) — vom FMM003, z. B.
|
||||
`sensor.<gerätename>_latitude_coordinate_value`/`..._longitude_coordinate_value`
|
||||
bzw. `sensor.<gerätename>_external_power_voltage` (**nicht**
|
||||
`..._battery_voltage` — das ist die interne Pufferzelle des Trackers,
|
||||
nicht die Fahrzeugbatterie)
|
||||
|
||||
@@ -91,6 +91,14 @@ def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
|
||||
|
||||
_ZAHL = r"[\d.,]+"
|
||||
|
||||
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
|
||||
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
|
||||
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
|
||||
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
|
||||
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
|
||||
# nennen aber nie den bezahlten Rechnungsbetrag.
|
||||
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
|
||||
|
||||
|
||||
def _parsen(text):
|
||||
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
|
||||
@@ -225,20 +233,57 @@ def _parsen_generisch(text):
|
||||
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
|
||||
return re.search(muster, text, flags)
|
||||
|
||||
# ---- Adresse: Name-/Straßen-/PLZ-Ort-Zeile hintereinander - wie bei
|
||||
# Shell (_parsen() oben), aber ohne Ankerzeile "Obj.-Nr." danach, also
|
||||
# direkt an der PLZ-Ort-Form (5 Ziffern + Text) erkannt. Nicht Pflicht.
|
||||
adresse = suchen(r"([^\n]{2,60})\n([^\n]{2,60})\n(\d{5}\s+[^\n]{2,40})")
|
||||
station_name = adresse.group(1).strip() if adresse else None
|
||||
station_address = (
|
||||
f"{adresse.group(2).strip()}, {adresse.group(3).strip()}" if adresse else None
|
||||
)
|
||||
zeilen = [z.rstrip() for z in text.splitlines()]
|
||||
|
||||
# ---- Adresse -----------------------------------------------------------
|
||||
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
|
||||
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
|
||||
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
|
||||
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
|
||||
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
|
||||
# Name ist die erste Textzeile darüber.
|
||||
plz_zeile = None
|
||||
for i, zeile in enumerate(zeilen[:12]):
|
||||
if re.search(r"\b\d{4,5}\s+[A-Za-zÄÖÜäöüß]", zeile):
|
||||
plz_zeile = i
|
||||
break
|
||||
station_name = None
|
||||
station_address = None
|
||||
if plz_zeile is not None:
|
||||
station_address = zeilen[plz_zeile].strip()
|
||||
for zeile in reversed(zeilen[:plz_zeile]):
|
||||
if zeile.strip():
|
||||
station_name = zeile.strip()
|
||||
break
|
||||
if station_name is None:
|
||||
for zeile in zeilen[:5]:
|
||||
if zeile.strip():
|
||||
station_name = zeile.strip()
|
||||
break
|
||||
|
||||
# ---- Gesamtbetrag ------------------------------------------------------
|
||||
gesamt = suchen(r"(?:Gesamt\w*|Absolut\w*|Endbetrag|Summe|Total)\D{0,20}?(" + _ZAHL + r")\s*(?:€|EUR)?")
|
||||
if not gesamt:
|
||||
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
|
||||
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
|
||||
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
|
||||
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
|
||||
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
|
||||
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
|
||||
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
|
||||
kandidaten = []
|
||||
for zeile in zeilen:
|
||||
kompakt = re.sub(r"\s+", "", zeile).upper()
|
||||
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
|
||||
continue
|
||||
if any(wort in kompakt for wort in _STEUER_WOERTER):
|
||||
continue
|
||||
betraege = re.findall(_GELD, zeile)
|
||||
if betraege:
|
||||
kandidaten.append(_de_zahl(betraege[-1]))
|
||||
if not kandidaten:
|
||||
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
|
||||
fuel_total_eur = _de_zahl(gesamt.group(1))
|
||||
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
|
||||
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
|
||||
fuel_total_eur = max(kandidaten)
|
||||
|
||||
# ---- Menge in Litern ----------------------------------------------------
|
||||
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
|
||||
@@ -253,8 +298,10 @@ def _parsen_generisch(text):
|
||||
berechneter_preis_l = round(fuel_total_eur / liters, 3)
|
||||
|
||||
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
|
||||
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
|
||||
# deshalb ist alles nach dem L optional.
|
||||
aufgedruckt = suchen(
|
||||
r"(?:Preis\s*/?\s*(?:je\s*)?Liter|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
|
||||
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
|
||||
)
|
||||
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
|
||||
|
||||
|
||||
@@ -103,14 +103,20 @@ def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None
|
||||
# task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind
|
||||
# in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur
|
||||
# echte externe Python-Funktionen (siehe Kopfkommentar in profil.py).
|
||||
inhalt_bytes = base64.b64decode(pdf_base64)
|
||||
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
|
||||
|
||||
#
|
||||
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
|
||||
# Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
|
||||
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
|
||||
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
|
||||
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
|
||||
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
|
||||
try:
|
||||
inhalt_bytes = base64.b64decode(pdf_base64)
|
||||
dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname)
|
||||
geparst = _parser_aufrufen(pdf_pfad)
|
||||
except Exception as exc:
|
||||
log.error(f"audi_dashboard: Beleg konnte nicht geparst werden: {exc}")
|
||||
log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}")
|
||||
frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id})
|
||||
return
|
||||
|
||||
|
||||
@@ -77,16 +77,10 @@ OELWECHSEL_STRECKE_SENSOR = ""
|
||||
NAECHSTE_INSPEKTION_SENSOR = ""
|
||||
INSPEKTION_STRECKE_SENSOR = ""
|
||||
|
||||
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Erwartet
|
||||
# eine device_tracker.*-Entity mit "latitude"/"longitude"-Attributen (HA-
|
||||
# Konvention, siehe _standort() in frontend_veroeffentlichung.py) - jetzt
|
||||
# vom FMM003 geliefert.
|
||||
STANDORT_TRACKER = "device_tracker.testzone_fmm003"
|
||||
|
||||
# Alternative zu STANDORT_TRACKER: manche Integrationen (z. B. flespi) liefern
|
||||
# Breiten-/Längengrad nicht als Attribute einer einzelnen device_tracker-
|
||||
# Entity, sondern als zwei eigene sensor-Entities. _standort() nutzt diese
|
||||
# beiden nur, wenn STANDORT_TRACKER leer ist oder keine Koordinaten liefert.
|
||||
# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/
|
||||
# Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so,
|
||||
# nicht als Attribute einer device_tracker-Entity - siehe _standort() in
|
||||
# frontend_veroeffentlichung.py).
|
||||
STANDORT_LAT_SENSOR = ""
|
||||
STANDORT_LON_SENSOR = ""
|
||||
|
||||
|
||||
@@ -109,18 +109,14 @@ FELDER = [
|
||||
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
|
||||
{"key": "STANDORT_TRACKER", "label": "GPS-Standort", "gruppe": "standort",
|
||||
"hinweis": "Braucht latitude/longitude-Attribute (HA-Konvention für device_tracker).",
|
||||
"domains": ["device_tracker"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["standort", "gps", "tracker", "position", "ortung"]},
|
||||
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
|
||||
"hinweis": "Nur nötig, falls die Integration Breiten-/Längengrad als zwei eigene Sensoren statt als Attribute einer GPS-Standort-Entity liefert (z. B. flespi).",
|
||||
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
|
||||
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["breitengrad", "latitude", "lat"]},
|
||||
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
|
||||
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
|
||||
"hinweis": "Nur nötig, falls die Integration Breiten-/Längengrad als zwei eigene Sensoren statt als Attribute einer GPS-Standort-Entity liefert (z. B. flespi).",
|
||||
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
|
||||
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["längengrad", "longitude", "lon", "lng"]},
|
||||
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
|
||||
]
|
||||
|
||||
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
|
||||
|
||||
@@ -88,59 +88,36 @@ def _abs_zahl(wert):
|
||||
|
||||
|
||||
def _standort():
|
||||
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Zwei
|
||||
mögliche Quellen, in dieser Reihenfolge versucht: (1) STANDORT_TRACKER,
|
||||
eine device_tracker-Entity mit latitude/longitude/gps_accuracy-Attributen
|
||||
(HA-Konvention) - der Normalfall; (2) STANDORT_LAT_SENSOR/
|
||||
STANDORT_LON_SENSOR, zwei eigene sensor-Entities für Breiten-/Längengrad -
|
||||
manche Integrationen (z. B. flespi) liefern Koordinaten so, statt als
|
||||
Attribute einer einzelnen Tracker-Entity. Fehlt beides oder ist (noch)
|
||||
nicht verfügbar, liefert diese Funktion durchgehend None statt eines
|
||||
geratenen Werts - genau das zustand_oder_none()-Muster von oben, hier als
|
||||
eigene Funktion, weil zusätzlich zum Zustand noch Attribute und der
|
||||
Aktualisierungszeitpunkt gebraucht werden: state.get() liefert dafür ein
|
||||
StateVal (str-Unterklasse) mit .last_updated als datetime-Attribut - kein
|
||||
eigenständiges state.last_updated(), das gibt es in pyscript nicht."""
|
||||
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
|
||||
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für
|
||||
Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute
|
||||
einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs
|
||||
oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion
|
||||
durchgehend None statt eines geratenen Werts - genau das
|
||||
zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil
|
||||
zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird:
|
||||
state.get() liefert dafür ein StateVal (str-Unterklasse) mit
|
||||
.last_updated als datetime-Attribut - kein eigenständiges
|
||||
state.last_updated(), das gibt es in pyscript nicht."""
|
||||
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
|
||||
|
||||
tracker_id = einstellungen.STANDORT_TRACKER
|
||||
if tracker_id:
|
||||
try:
|
||||
wert = state.get(tracker_id)
|
||||
attribute = state.getattr(tracker_id)
|
||||
except NameError:
|
||||
attribute = None
|
||||
if attribute and attribute.get("latitude") is not None:
|
||||
zeit = None
|
||||
try:
|
||||
zeit = wert.last_updated.isoformat()
|
||||
except AttributeError:
|
||||
pass
|
||||
return {
|
||||
"lat": _zu_zahl(attribute.get("latitude")),
|
||||
"lon": _zu_zahl(attribute.get("longitude")),
|
||||
"genauigkeit_m": _zu_zahl(attribute.get("gps_accuracy")),
|
||||
"zeit": zeit,
|
||||
}
|
||||
|
||||
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
|
||||
if lat_id and lon_id:
|
||||
try:
|
||||
lat_wert = state.get(lat_id)
|
||||
lon_wert = state.get(lon_id)
|
||||
except NameError:
|
||||
return leer
|
||||
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
|
||||
if lat is None or lon is None:
|
||||
return leer
|
||||
zeit = None
|
||||
try:
|
||||
zeit = lat_wert.last_updated.isoformat()
|
||||
except AttributeError:
|
||||
pass
|
||||
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
|
||||
|
||||
return leer
|
||||
if not lat_id or not lon_id:
|
||||
return leer
|
||||
try:
|
||||
lat_wert = state.get(lat_id)
|
||||
lon_wert = state.get(lon_id)
|
||||
except NameError:
|
||||
return leer
|
||||
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
|
||||
if lat is None or lon is None:
|
||||
return leer
|
||||
zeit = None
|
||||
try:
|
||||
zeit = lat_wert.last_updated.isoformat()
|
||||
except AttributeError:
|
||||
pass
|
||||
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
|
||||
|
||||
|
||||
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
|
||||
|
||||
@@ -386,31 +386,57 @@ function ring(pct) {
|
||||
/* ------------------------------------------------------------------ Karte
|
||||
Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */
|
||||
let MAP = null, TILE = null, LEAFLET_LADEND = null;
|
||||
const TILES = {
|
||||
nacht: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||
tag: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
||||
};
|
||||
/* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die
|
||||
Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen
|
||||
und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten
|
||||
dark_all-Kacheln liessen die Karte im Panel schwarz wirken. */
|
||||
const TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
||||
const ATTR = "© OpenStreetMap · CARTO";
|
||||
/* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die
|
||||
CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */
|
||||
const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
|
||||
const SAT_ATTR = "Tiles © Esri";
|
||||
|
||||
function leafletLaden() {
|
||||
if (window.L) return Promise.resolve();
|
||||
if (LEAFLET_LADEND) return LEAFLET_LADEND;
|
||||
LEAFLET_LADEND = new Promise((resolve, reject) => {
|
||||
const css1 = document.createElement("link");
|
||||
css1.rel = "stylesheet";
|
||||
css1.href = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css";
|
||||
document.head.appendChild(css1);
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js";
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css";
|
||||
const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js";
|
||||
|
||||
/* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head:
|
||||
Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head,
|
||||
fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen
|
||||
dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px
|
||||
hoch), von der Karte war nur ein schmaler Streifen zu sehen und der
|
||||
Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS
|
||||
gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */
|
||||
function leafletCssEinhaengen() {
|
||||
if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve();
|
||||
return new Promise((fertig) => {
|
||||
const css = document.createElement("link");
|
||||
css.rel = "stylesheet";
|
||||
css.href = LEAFLET_CSS_URL;
|
||||
css.dataset.leafletCss = "1";
|
||||
/* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht
|
||||
die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */
|
||||
css.onload = fertig;
|
||||
css.onerror = fertig;
|
||||
ROOT.appendChild(css);
|
||||
});
|
||||
return LEAFLET_LADEND;
|
||||
}
|
||||
|
||||
function leafletLaden() {
|
||||
/* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer
|
||||
Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */
|
||||
const css = leafletCssEinhaengen();
|
||||
if (window.L) return css;
|
||||
if (!LEAFLET_LADEND) {
|
||||
LEAFLET_LADEND = new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = LEAFLET_JS_URL;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
return Promise.all([css, LEAFLET_LADEND]);
|
||||
}
|
||||
|
||||
function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; }
|
||||
@@ -437,7 +463,7 @@ function fakeTrack(a, b, n = 13) {
|
||||
}
|
||||
function tiles() {
|
||||
if (!MAP || !window.L) return;
|
||||
const url = TILES[HOST.dataset.theme];
|
||||
const url = TILE_URL;
|
||||
if (TILE) MAP.removeLayer(TILE);
|
||||
TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP);
|
||||
}
|
||||
@@ -498,10 +524,15 @@ const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss
|
||||
|
||||
function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; }
|
||||
|
||||
/* Kartenmarkierung: poi-car aus dem Audi-CI-Satz (assets/icons/poi-car.svg).
|
||||
Die Spitze sitzt unten, deshalb ankert Leaflet am unteren Rand. */
|
||||
/* Kartenmarkierung: poi-car aus dem CI-Satz, in den beiden gelieferten
|
||||
Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter
|
||||
poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet
|
||||
am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25
|
||||
im 48er- bzw. y=23.3 im 24er-viewBox). */
|
||||
function fahrzeugMarkerSVG(px = 34) {
|
||||
return `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">${CI.poiCar}</svg>`;
|
||||
const gross = px >= 34;
|
||||
const box = gross ? 48 : 24;
|
||||
return `<svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true">${gross ? CI.poiCarL : CI.poiCar}</svg>`;
|
||||
}
|
||||
|
||||
/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist
|
||||
@@ -576,7 +607,7 @@ function standortErfassenFallsNoetig() {
|
||||
}
|
||||
|
||||
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
|
||||
wie die Kartenkacheln oben (TILES) ohne eigenen Schlüssel; bei diesem
|
||||
wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
|
||||
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
|
||||
der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits
|
||||
dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel
|
||||
@@ -639,14 +670,14 @@ function standortTiles() {
|
||||
if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE);
|
||||
SMAP_TILE = SMAP_STIL === "satellit"
|
||||
? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP)
|
||||
: window.L.tileLayer(TILES[HOST.dataset.theme], { maxZoom: 19, attribution: ATTR }).addTo(SMAP);
|
||||
: window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP);
|
||||
}
|
||||
function standortMarkerZeichnen() {
|
||||
if (!SMAP || !window.L) return;
|
||||
if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; }
|
||||
if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; }
|
||||
if (standortBekannt()) {
|
||||
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(38)}</div>`, iconSize: [38, 38], iconAnchor: [18, 36] });
|
||||
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(38)}</div>`, iconSize: [38, 38], iconAnchor: [19, 36] });
|
||||
FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP);
|
||||
FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen());
|
||||
}
|
||||
@@ -693,10 +724,10 @@ async function initVorschauMap() {
|
||||
zoomControl: false, attributionControl: false, dragging: false,
|
||||
scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false,
|
||||
});
|
||||
TMAP_TILE = window.L.tileLayer(TILES[HOST.dataset.theme], { maxZoom: 19, attribution: ATTR }).addTo(TMAP);
|
||||
TMAP_TILE = window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP);
|
||||
if (standortBekannt()) {
|
||||
TMAP.setView([CAR.standortLat, CAR.standortLon], 14);
|
||||
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 30] });
|
||||
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 31] });
|
||||
window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP);
|
||||
} else {
|
||||
TMAP.setView(kartenMittelpunkt(), 11);
|
||||
@@ -781,6 +812,9 @@ const CI = {
|
||||
carSearch: '<path fill="currentColor" d="M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z"/>',
|
||||
gps: '<path fill="currentColor" d="M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z"/>',
|
||||
poiCar: '<path fill="currentColor" d="M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z"/>',
|
||||
/* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die
|
||||
Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */
|
||||
poiCarL: '<path fill="currentColor" d="M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z"/>',
|
||||
selectAll: '<path fill="currentColor" d="M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z"/>',
|
||||
planRoute: '<path fill="currentColor" d="m18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z"/>',
|
||||
upload: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>',
|
||||
@@ -3103,11 +3137,9 @@ function ereignisseVerdrahten() {
|
||||
ROOT.getElementById("theme").addEventListener("click", () => {
|
||||
themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht");
|
||||
themeIcon();
|
||||
/* Neu zeichnen, damit auch die Leaflet-Kacheln den Stil wechseln: die
|
||||
Kachel-URL wird beim Aufbau aus TILES[theme] gewaehlt und nicht
|
||||
nachtraeglich aktualisiert. Ohne dies blieb die Karte bis zum naechsten
|
||||
Backend-Update im alten Stil - seit der Dauerkarte auf der Uebersicht
|
||||
deutlich sichtbar. */
|
||||
/* Neu zeichnen, damit theme-abhaengige, in JS gesetzte Werte (z. B. die
|
||||
aus --red gelesene Routenfarbe in initMap) mitziehen. Die Kacheln selbst
|
||||
sind seit TILE_URL themeunabhaengig hell und brauchen das nicht mehr. */
|
||||
render();
|
||||
});
|
||||
// Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe
|
||||
@@ -3153,6 +3185,9 @@ function ereignisseVerdrahten() {
|
||||
}
|
||||
e.preventDefault();
|
||||
swipeWarGezogen = true;
|
||||
// Loeschknopf erst waehrend der Geste einblenden (siehe .swipe-delete in
|
||||
// audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch.
|
||||
swipeAktivWrap.classList.add("wischt");
|
||||
const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0;
|
||||
swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx));
|
||||
const inhalt = swipeAktivWrap.querySelector(".swipe-content");
|
||||
@@ -3163,6 +3198,7 @@ function ereignisseVerdrahten() {
|
||||
if (!swipeAktivWrap) return;
|
||||
const inhalt = swipeAktivWrap.querySelector(".swipe-content");
|
||||
inhalt.style.transition = ""; inhalt.style.transform = "";
|
||||
swipeAktivWrap.classList.remove("wischt");
|
||||
if (swipeGeste === "x") {
|
||||
if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); }
|
||||
else { swipeAktivWrap.classList.remove("swiped"); }
|
||||
@@ -3510,7 +3546,21 @@ function ereignisseVerdrahten() {
|
||||
const leser = new FileReader();
|
||||
leser.onload = () => {
|
||||
const base64 = leser.result.split(",")[1];
|
||||
serviceRufen("audi_dashboard_beleg_hochladen", { pdf_base64: base64, dateiname: datei.name, tank_id: tankId });
|
||||
// Kein serviceRufen() hier: dessen catch loggt nur in die Browser-
|
||||
// Konsole (bei allen anderen Aufrufen ok, die reagieren auf
|
||||
// datenLaden() statt auf den Rückgabewert) - ein rejizierter
|
||||
// Service-Aufruf (Backend nicht erreichbar, Timeout, ...) blieb
|
||||
// für den Beleg-Upload sonst komplett unsichtbar in der
|
||||
// Oberfläche, obwohl der Backend-seitige Parser-Fehlerpfad selbst
|
||||
// schon einen Fehler veröffentlicht (siehe belegverarbeitung.py).
|
||||
HASS.callService("pyscript", "audi_dashboard_beleg_hochladen",
|
||||
{ pdf_base64: base64, dateiname: datei.name, tank_id: tankId }
|
||||
).catch((err) => {
|
||||
console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err);
|
||||
belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" };
|
||||
if (!tankId) belegEntwurf = null;
|
||||
render();
|
||||
});
|
||||
};
|
||||
leser.readAsDataURL(datei);
|
||||
};
|
||||
|
||||
@@ -140,10 +140,11 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
|
||||
padding:8px 4px;color:var(--fg2)}
|
||||
/* Design-Audit 2026-08-13: ein <select> (z.B. Oelwechsel-Intervall "10.000 km"/
|
||||
"1 Jahr") sah wie reiner, nicht editierbarer Text aus, seit hier Rahmen/
|
||||
Flaeche entfernt wurden - anders als bei freien Texteingaben zeigt iOS bei
|
||||
Picker-Feldern wie diesem ueblicherweise die Akzentfarbe statt Fliesstext-
|
||||
Grau als Hinweis "hier gibt es eine Auswahl". */
|
||||
.feld select{color:var(--ios-tint)}
|
||||
Flaeche entfernt wurden. Der Hinweis "hier gibt es eine Auswahl" laeuft
|
||||
deshalb ueber dieselbe graue Flaeche wie bei .mitEinheit input (Feld
|
||||
"Pause bis [15] Minuten") - ein Kasten liest sich als bedienbar, waehrend
|
||||
eingefaerbter Text auch bloss Hervorhebung sein koennte. */
|
||||
.feld select{background:var(--ios-fill);padding:8px 10px}
|
||||
.feld input:focus,.feld select:focus{background:var(--ios-fill);color:var(--fg)}
|
||||
.mitEinheit input{background:var(--ios-fill);padding:8px 10px}
|
||||
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version": 1786752000}
|
||||
{"version": 1786765000}
|
||||
|
||||
@@ -667,7 +667,15 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
|
||||
|
||||
/* -------------------------------------------------------------- Wischen */
|
||||
.swipe-wrap { position: relative; overflow: hidden; }
|
||||
.swipe-delete { position: absolute; inset: 0 0 0 auto; width: 84px; display: flex; }
|
||||
/* Der rote Loeschknopf liegt dauerhaft hinter der Zeile. Zeilenhoehen sind
|
||||
gebrochen (z. B. 66,28 px), dadurch fiel die Kante von .swipe-content nicht
|
||||
aufs Geraetepixelraster und der rote Knopf blitzte als duenne Linie an den
|
||||
Zeilenraendern durch - sichtbar als roter Teilrahmen um jede Listenzeile.
|
||||
Er wird deshalb erst sichtbar, sobald wirklich gewischt wird ("wischt"
|
||||
waehrend der Geste, "swiped" fuer die offen stehende Zeile). */
|
||||
.swipe-delete { position: absolute; inset: 0 0 0 auto; width: 84px; display: flex; visibility: hidden; }
|
||||
.swipe-wrap.wischt .swipe-delete,
|
||||
.swipe-wrap.swiped .swipe-delete { visibility: visible; }
|
||||
.swipe-loeschen {
|
||||
width: 100%;
|
||||
background: var(--bad); color: #fff; border: none;
|
||||
@@ -702,12 +710,16 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
|
||||
}
|
||||
|
||||
.fahrzeug-pin-wrap { background: none; border: none; }
|
||||
/* poi-car aus dem CI-Satz - rotes Symbol, weiss abgesetzt statt in einem
|
||||
Kasten, damit die Spitze exakt auf der Position steht. */
|
||||
/* poi-car aus dem CI-Satz ist eine reine Konturform in currentColor - auf
|
||||
Kartenkacheln waere sie ohne Trennung zum Untergrund kaum lesbar. Die
|
||||
gestapelten weissen drop-shadows ziehen deshalb einen Halo um die Kontur,
|
||||
der letzte Schatten gibt Tiefe. Feste Farbe statt Theme-Variable, damit die
|
||||
Nadel in Tag- und Nachtansicht gleich aussieht. */
|
||||
.fahrzeug-pin {
|
||||
color: var(--red);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 2px #fff) drop-shadow(0 2px 5px rgba(0,0,0,.45));
|
||||
color: #182634;
|
||||
filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 1px #fff)
|
||||
drop-shadow(0 0 2px #fff) drop-shadow(0 2px 4px rgba(0,0,0,.4));
|
||||
}
|
||||
.user-pin-wrap { background: none; border: none; }
|
||||
.user-pin {
|
||||
|
||||
Reference in New Issue
Block a user