diff --git a/AGENTS.md b/AGENTS.md index bb0ca76..239b0a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,6 +253,29 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac viewport-anchored, not parent-relative) inside `@container (min-width:860px)`. Verified live at both narrow (mobile tab bar, full content) and wide (1144px container, grid side-nav, navigation clicks, settings back-arrow) — screenshots taken, no regressions found. +- [x] Standort-Kachel (live vehicle GPS position on the Übersicht, above "Zuletzt") — done + 2026-08-12. New tile with a real (non-fake) Leaflet mini-map, opens a fullscreen "Standort" + route on tap; fullscreen has 4 floating controls (map style, center-on-vehicle, + center-on-user, fit-both) and a draggable myAudi-style bottom sheet (peek/expand/close by + drag or tap, opens on vehicle-marker click) showing distance-to-user, address (client-side + reverse geocoding via the public Nominatim API — same "public API, no key, low single- + vehicle volume" reasoning as the existing Leaflet-CDN gap below), fährt/steht/"Letzter + Parkplatz" state, fuel/range, and Route (Google Maps deep link) / Teilen (Web Share API) + actions. Backend: new `STANDORT_TRACKER` entity-ID setting in `einstellungen.py` (device_ + tracker with lat/lon attributes), published via a new `_standort()` helper in + `frontend_veroeffentlichung.py` — **left empty/unconfigured**, no live vehicle GPS source + exists yet (same FMM003/flespi gap as section B); the tile shows "Kein GPS-Signal vom + Fahrzeug" until that's wired up. Verified end-to-end in `audi_ha_test` with a manually + created `device_tracker.test_fahrzeug` test entity (tile, fullscreen map, and menu all + confirmed rendering/populating correctly); the device's own location (browser Geolocation + API) and the Route/Teilen deep links work independently of the vehicle-GPS gap. Pushed to + the Claude Design project (`c28a8d4d-ec4e-4178-9e49-ab5b90c02097`, "DataMetric360 Board") + for visual refinement — the JS/CSS ship functionally complete but visually plain + (placeholder vehicle-marker glyph, default `.aktion`/`.tile` styling); Claude Design's job is + polish, not structure. One known rough edge: reverse-geocoded address didn't resolve within + a few seconds in the `audi_ha_test` container (no crash, stays on "Adresse wird ermittelt …" + indefinitely) — likely just that container's outbound network to Nominatim specifically; + unconfirmed whether this reproduces on the real HA instance. - [ ] Fix documentation drift (statistics claim, INSTALL variable names, README gaps, obsolete TODO comment) — text-only changes - [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` diff --git a/homeassistant/pyscript/modules/einstellungen.py b/homeassistant/pyscript/modules/einstellungen.py index 82cdda8..9037bb0 100644 --- a/homeassistant/pyscript/modules/einstellungen.py +++ b/homeassistant/pyscript/modules/einstellungen.py @@ -100,6 +100,14 @@ OELWECHSEL_STRECKE_SENSOR = "sensor.audi_rs_4_avant_oil_change_distance" NAECHSTE_INSPEKTION_SENSOR = "sensor.audi_rs_4_avant_inspection_due" INSPEKTION_STRECKE_SENSOR = "sensor.audi_rs_4_avant_inspection_distance" +# 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) - bleibt +# leer, solange keine Quelle angebunden ist (siehe AGENTS.md Abschnitt B, +# FMM003/flespi offen); die Oberfläche zeigt dann "kein GPS-Signal" statt +# eines falschen Werts, genau wie bei BATTERIE_SENSOR oben. +STANDORT_TRACKER = "" + # Update-Funktion (Einstellungen -> "Update suchen", siehe # updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird - # z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py index 521d9d3..acfc1c7 100644 --- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py +++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py @@ -77,6 +77,40 @@ def _abs_zahl(wert): return None if zahl is None else abs(zahl) +def _standort(): + """Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest + latitude/longitude/gps_accuracy von der in STANDORT_TRACKER hinterlegten + device_tracker-Entity (HA-Konvention). Fehlt die Entity-ID oder ist sie + (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.""" + entity_id = einstellungen.STANDORT_TRACKER + leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None} + if not entity_id: + return leer + try: + wert = state.get(entity_id) + attribute = state.getattr(entity_id) + except NameError: + return leer + if not attribute or attribute.get("latitude") is None: + return leer + 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, + } + + POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"] @@ -125,6 +159,8 @@ def fahrzeugstatus_veroeffentlichen(): else: gesichert = all([e["ok"] for e in sicherheitscheck]) + standort = _standort() + daten = { "km": _zu_zahl(km), "tankprozent": _zu_zahl(tankprozent), @@ -132,6 +168,10 @@ def fahrzeugstatus_veroeffentlichen(): "batteriespannung": _zu_zahl(batteriespannung), "gesichert": gesichert, "sicherheitscheck": sicherheitscheck, + "standort_lat": standort["lat"], + "standort_lon": standort["lon"], + "standort_genauigkeit_m": standort["genauigkeit_m"], + "standort_zeit": standort["zeit"], # Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die # App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im # Frontend) - die Streckensensoren liefern negative Restkilometer- diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js index eaaaa73..c6be1b7 100644 --- a/homeassistant/www/audi-dashboard-app.js +++ b/homeassistant/www/audi-dashboard-app.js @@ -112,6 +112,10 @@ function profilZuCar(p, status) { batteriespannung: status.batteriespannung, gesichert: status.gesichert, sicherheitscheck: status.sicherheitscheck || [], + standortLat: status.standort_lat, + standortLon: status.standort_lon, + standortGenauigkeitM: status.standort_genauigkeit_m, + standortZeit: status.standort_zeit, oelwechselFaelligTs: status.oelwechsel_faellig_ts, oelwechselFaelligKm: status.oelwechsel_faellig_km, inspektionFaelligTs: status.inspektion_faellig_ts, @@ -457,6 +461,318 @@ async function initMap() { setTimeout(() => MAP && MAP.invalidateSize(), 60); } +/* ---------------------------------------------------------------- Standort + Live-Position des Fahrzeugs (Übersicht-Kachel + Vollbildansicht) und des + Endgeräts (Browser-Geolocation). Bewusst außerhalb des sonst + durchgängigen "ein render() zeichnet alles neu"-Musters: eine Leaflet- + Karte verliert bei jedem Neuaufbau ihres DOM-Containers Kartenausschnitt + und Zoomstufe (genau wie MAP oben schon), und das Fahrzeug-Menü soll sich + beim Öffnen/Schließen/Ziehen nicht wie der Rest der App anfühlen (kein + kompletter Neuaufbau bei jedem Fingertipp). initMap() oben handhabt genau + dasselbe Problem bereits für Fahrt-/Belegkarten - hier nur konsequent für + eine interaktive Karte mit eigenem Bedienelement weitergedacht: Standort- + und Nutzerdaten ändern CAR/USER_POS und lösen ein normales render() aus + (Kartenneuaufbau inklusive, wie bei MAP), aber Menü-Auf/Zu/Ziehen und die + Zentrieren-Knöpfe manipulieren SMAP und das Menü-DOM direkt. */ +let USER_POS = null; // {lat, lon, genauigkeit} oder null +let USER_POS_TS = 0; +let USER_POS_FEHLER = null; +let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung) +let STANDORT_ADRESSE_KEY = null; +let STANDORTMENU_OFFEN = false; +let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP) +let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel +let FAHRZEUG_MARKER = null, USER_MARKER = null; +const STANDORT_PEEK_PX = 108; // muss zu --standort-peek in audi-dashboard.css passen + +function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; } + +/* Fahrzeug-Symbol im myAudi-Stil: einfaches Top-down-Glyph, wiederverwendet + für Kachel-/Vollbild-Marker und den "Auf Fahrzeug zentrieren"-Knopf. */ +function fahrzeugGlyphPfade() { + return ` + + `; +} +function fahrzeugMarkerSVG() { + return ` + ${fahrzeugGlyphPfade()} + + + `; +} + +/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist + (siehe fahrterkennung.py), sonst "steht" - erst ab der in "Fahrten + zusammenfassen" hinterlegten Pausenzeit (CONFIG.pauseMin) gilt der + Stillstand als endgültig genug für "Letzter Parkplatz" statt eines + bloßen Zwischenstopps. Ohne echtes Bewegungssignal vom Fahrzeug (siehe + AGENTS.md, FMM003/flespi offen) ist das die beste Näherung aus den + bereits vorhandenen Fahrtdaten. */ +function standortZustand() { + const t = TRIPS[0]; + if (t && t.status === "offen") return { faehrt: true, seitTs: null, parkplatz: false }; + const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null; + const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null; + return { faehrt: false, seitTs, parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15) }; +} +function dauerSeitText(ts) { + if (ts == null) return "unbekannt"; + const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + const tage = Math.floor(sekunden / 86400); + const stunden = Math.floor((sekunden % 86400) / 3600); + const minuten = Math.floor((sekunden % 3600) / 60); + if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`; + if (stunden > 0) return `${stunden} Std. ${minuten} Min.`; + return `${minuten} Min.`; +} + +/* Haversine-Näherung für die Distanzangabe oben links im Menü ("15 m"). */ +function distanzMeter(a, b) { + if (!a || !b) return null; + const R = 6371000, rad = Math.PI / 180; + const dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad; + const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); +} +function distanzText(m) { return m == null ? "" : m < 1000 ? Math.round(m) + " m" : de(m / 1000, 1) + " km"; } + +function standortErfassen() { + if (!navigator.geolocation) { USER_POS_FEHLER = "Kein Zugriff auf den Standort dieses Geräts"; return; } + navigator.geolocation.getCurrentPosition( + (pos) => { + USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy }; + USER_POS_TS = Date.now(); USER_POS_FEHLER = null; + standortAnsichtAktualisieren(); + }, + (err) => { USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "Standortzugriff verweigert" : "Standort nicht verfügbar"; }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } + ); +} +function standortErfassenFallsNoetig() { + if (USER_POS && Date.now() - USER_POS_TS < 30000) return; + standortErfassen(); +} + +/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - + wie die Kartenkacheln oben (TILES) 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 + auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an + derselben Stelle keinen zweiten Abruf auslöst. */ +async function standortAdresseAufloesen(lat, lon) { + const key = lat.toFixed(3) + "," + lon.toFixed(3); + if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE; + try { + const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "de" } }); + const daten = await res.json(); + const a = daten.address || {}; + const strasse = [a.road, a.house_number].filter(Boolean).join(" "); + STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || ""; + } catch (e) { + STANDORT_ADRESSE = null; + } + STANDORT_ADRESSE_KEY = key; + return STANDORT_ADRESSE; +} +function standortAdresseLaden() { + if (!standortBekannt()) return; + standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => { + const el = $("#standortAdresse"); + if (el) el.textContent = adresse || "Adresse nicht ermittelbar"; + }); +} + +function standortKartenlink() { + return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null; +} +async function standortTeilen() { + const url = standortKartenlink(); + if (!url) return; + const titel = `${CONFIG.fahrzeugtitel} · Standort`; + if (navigator.share) { + try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ } + return; + } + try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); } + catch (e) { hinweis("Teilen nicht möglich", url); } +} + +/* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und + ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */ +function standortAnsichtAktualisieren() { + if (route.name !== "standort") return; + standortMarkerZeichnen(); + const dist = $("#standortDistanz"); + if (dist) dist.textContent = USER_POS && standortBekannt() ? distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) : ""; +} + +function standortTiles() { + if (!SMAP || !window.L) return; + const stil = SMAP_STIL || HOST.dataset.theme; + const url = TILES[stil]; + if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE); + SMAP_TILE = window.L.tileLayer(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: `
${fahrzeugMarkerSVG()}
`, iconSize: [34, 34], iconAnchor: [17, 17] }); + FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP); + FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen()); + } + if (USER_POS) { + const icon = window.L.divIcon({ className: "user-pin-wrap", html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); + USER_MARKER = window.L.marker([USER_POS.lat, USER_POS.lon], { icon, zIndexOffset: -100 }).addTo(SMAP); + } +} +function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); } +function standortAufUser() { + if (!USER_POS) { standortErfassen(); return; } + if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15)); +} +function standortBeideZeigen() { + if (!SMAP) return; + if (standortBekannt() && USER_POS) SMAP.fitBounds([[CAR.standortLat, CAR.standortLon], [USER_POS.lat, USER_POS.lon]], { padding: [64, 64], maxZoom: 16 }); + else if (standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], 15); + else if (USER_POS) SMAP.flyTo([USER_POS.lat, USER_POS.lon], 15); +} + +async function initStandortMap() { + STANDORTMENU_OFFEN = false; + await leafletLaden(); + const el = ROOT.getElementById("smap"); + if (!el || typeof window.L === "undefined") return; + SMAP = window.L.map(el, { zoomControl: false, attributionControl: false }); + SMAP_TILE = null; standortTiles(); + standortMarkerZeichnen(); + standortBeideZeigen(); + if (!standortBekannt() && !USER_POS) SMAP.setView(kartenMittelpunkt(), 11); + standortAdresseLaden(); + standortErfassenFallsNoetig(); + setTimeout(() => SMAP && SMAP.invalidateSize(), 60); +} +async function initVorschauMap() { + await leafletLaden(); + const el = ROOT.getElementById("tmap"); + if (!el || typeof window.L === "undefined") return; + TMAP = window.L.map(el, { + 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); + if (standortBekannt()) { + const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG()}
`, iconSize: [30, 30], iconAnchor: [15, 15] }); + window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP); + TMAP.setView([CAR.standortLat, CAR.standortLon], 14); + } else { + TMAP.setView(kartenMittelpunkt(), 11); + } + standortErfassenFallsNoetig(); + setTimeout(() => TMAP && TMAP.invalidateSize(), 60); +} + +function standortMenuElement() { return ROOT.getElementById("standortmenu"); } +function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; } +function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; } +function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); } + +/* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette, + bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse, + anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar + Standort-Abschnitt). */ +let smY0 = null, smOffenBeimStart = false; +function standortMenuVerdrahten() { + ROOT.addEventListener("pointerdown", (e) => { + if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return; + smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN; + const el = standortMenuElement(); if (el) el.style.transition = "none"; + }); + ROOT.addEventListener("pointermove", (e) => { + if (smY0 === null) return; + e.preventDefault(); + const el = standortMenuElement(); if (!el) return; + const dy = e.clientY - smY0; + const basis = smOffenBeimStart ? 0 : STANDORT_PEEK_PX; + const versatz = Math.max(0, Math.min(STANDORT_PEEK_PX, basis + dy)); + el.style.transform = `translateY(${versatz}px)`; + }, { passive: false }); + ROOT.addEventListener("pointerup", (e) => { + if (smY0 === null) return; + const dy = e.clientY - smY0; + const el = standortMenuElement(); + if (el) { el.style.transition = ""; el.style.transform = ""; } + smY0 = null; + if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); } + else standortMenuUmschalten(); + }); +} + +function standortKachel() { + return `
+ +
`; +} + +function vStandort() { + const zustand = standortZustand(), bekannt = standortBekannt(); + const distanz = USER_POS && bekannt ? distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) : ""; + const adresseKey = bekannt ? CAR.standortLat.toFixed(3) + "," + CAR.standortLon.toFixed(3) : null; + const adresseText = !bekannt ? "Kein GPS-Signal vom Fahrzeug" + : STANDORT_ADRESSE_KEY === adresseKey ? (STANDORT_ADRESSE || "Adresse nicht ermittelbar") : "Adresse wird ermittelt …"; + const link = standortKartenlink(); + return ` +
+
+
+ + + + +
+
+
+
+ +
+ ${distanz} +
${esc(CONFIG.fahrzeugtitel)}
+
${esc(adresseText)}
+
+
+
+ ${zustand.faehrt ? `Fahrzeug fährt` + : zustand.parkplatz ? `Letzter Parkplatz · geparkt seit ${dauerSeitText(zustand.seitTs)}` + : `Fahrzeug steht${zustand.seitTs != null ? " · seit " + dauerSeitText(zustand.seitTs) : ""}`} +
+
+
Tankfüllstand
${de(CAR.tankPct)} %
+
Restreichweite
${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "unbekannt"}
+
+
+ Route + +
+
+
`; +} + /* -------------------------------------------------------------- Ansichten Ab hier praktisch unverändert aus dem Prototyp übernommen. */ function badge() { @@ -666,6 +982,7 @@ function vHome() { return `
${artLabel}
${teileHU.join("/ ")}
`; })()} + ${standortKachel()} ${teaser()}`; } @@ -2143,6 +2460,7 @@ const ZURUECK = { /* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren Zurück-Pfeil. */ sicherheit: "home", + standort: "home", }; const ICON = { sonne: '', @@ -2220,6 +2538,7 @@ function render() { else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); } else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); } else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); } + else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); } else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title]; v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); } $("#eyebrow").textContent = head[0]; $("#title").textContent = head[1]; @@ -2242,6 +2561,10 @@ function render() { letzteAnsicht = route.name + "|" + (route.id || ""); if (MAP) { MAP.remove(); MAP = null; TILE = null; } if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer + if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } + if (route.name === "standort") initStandortMap().catch(() => {}); + if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } + if (route.name === "home") initVorschauMap().catch(() => {}); ROOT.querySelectorAll(".tab").forEach((el) => el.classList.toggle("on", el.dataset.tab === route.name)); } const go = (name, id) => { @@ -2748,6 +3071,14 @@ function ereignisseVerdrahten() { if (a) { const b = ROOT.getElementById(a.dataset.acc); a.setAttribute("aria-expanded", b.classList.toggle("on")); return; } const art = e.target.closest("[data-art]"); if (art) { const tr = TRIPS.find((x) => x.trip_id === art.dataset.art); tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; render(); return; } + // Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü- + // DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt). + if (e.target.closest("[data-standort-layer]")) { SMAP_STIL = (SMAP_STIL || HOST.dataset.theme) === "nacht" ? "tag" : "nacht"; standortTiles(); return; } + if (e.target.closest("[data-standort-fahrzeug]")) { standortAufFahrzeug(); return; } + if (e.target.closest("[data-standort-user]")) { standortAufUser(); return; } + if (e.target.closest("[data-standort-beide]")) { standortBeideZeigen(); return; } + if (e.target.closest("[data-standort-schliessen]")) { standortMenuSchliessen(); return; } + if (e.target.closest("[data-standort-teilen]")) { standortTeilen(); return; } const g = e.target.closest("[data-go]"); if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); } }); @@ -3126,6 +3457,7 @@ class AudiDashboardPanel extends HTMLElement { ereignisseVerdrahten(); randwischenVerdrahten(); + standortMenuVerdrahten(); themeIcon(); setInterval(standAlterTicken, 1000); } diff --git a/homeassistant/www/audi-dashboard-version.json b/homeassistant/www/audi-dashboard-version.json index 9f03e94..77b4125 100644 --- a/homeassistant/www/audi-dashboard-version.json +++ b/homeassistant/www/audi-dashboard-version.json @@ -1 +1 @@ -{"version": 1786473562} +{"version": 1786524729} diff --git a/homeassistant/www/audi-dashboard.css b/homeassistant/www/audi-dashboard.css index 55a25e9..8ec5d46 100644 --- a/homeassistant/www/audi-dashboard.css +++ b/homeassistant/www/audi-dashboard.css @@ -645,6 +645,89 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; } .mapbox { border-radius: var(--r-tile); overflow: hidden; height: 210px; background: var(--tile); } .mapbox #map { width: 100%; height: 100%; } +/* --------------------------------------------------------- Standort + Vorschau-Kachel (Übersicht) + Vollbildansicht + Fahrzeug-Menü. Höhen sind + bewusst fest statt prozentual/vererbt - eine relative Höhe wird von + panel_custom nicht zuverlässig durchgereicht (siehe audi-dashboard- + ios.css-Kommentar zur selben Falle). */ +.standort-kachel { padding: 0; overflow: hidden; } +.standort-kachelbtn { + display: block; width: 100%; position: relative; + border: none; padding: 0; margin: 0; background: none; cursor: pointer; +} +.standort-vorschau { width: 100%; height: 190px; background: var(--tile); } +.standort-leer { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-size: 13px; color: var(--fg3); text-align: center; padding: 0 30px; + background: var(--tile); +} + +.fahrzeug-pin-wrap { background: none; border: none; } +.fahrzeug-pin { + width: 34px; height: 34px; border-radius: 10px; + background: var(--red); border: 2px solid #fff; + box-shadow: 0 3px 10px rgba(0,0,0,.35); + display: flex; align-items: center; justify-content: center; +} +.user-pin-wrap { background: none; border: none; } +.user-pin { + position: relative; width: 16px; height: 16px; border-radius: 50%; + background: #0A84FF; border: 3px solid #fff; + box-shadow: 0 2px 6px rgba(0,0,0,.3); +} +.user-pin::after { + content: ""; position: absolute; inset: -14px; border-radius: 50%; + background: rgba(10,132,255,.18); + animation: user-pulse 2.4s ease-out infinite; +} +@keyframes user-pulse { 0% { transform: scale(.4); opacity: .9; } 100% { transform: scale(1); opacity: 0; } } + +.standort-vollbild { position: relative; height: min(640px, calc(100dvh - 190px)); border-radius: var(--r-tile); overflow: hidden; margin: 0 -20px; } +.standort-karte { width: 100%; height: 100%; } + +.kartensteuerung { + position: absolute; z-index: 400; top: 16px; right: 16px; + display: flex; flex-direction: column; gap: 10px; +} +.kartensteuerung button { + width: 42px; height: 42px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: var(--tile); color: var(--fg); + border: none; box-shadow: 0 3px 10px rgba(0,0,0,.25); + cursor: pointer; padding: 0; +} +.kartensteuerung button:active { background: var(--tile-2); transform: scale(.94); } + +/* Griffzone/Zug-Amplitude (STANDORT_PEEK_PX im Frontend) - beide Werte + müssen zusammenpassen, siehe Kommentar dort. */ +.standortmenu { + position: absolute; z-index: 410; left: 0; right: 0; bottom: 0; + background: var(--tile-2); + border-radius: 20px 20px 0 0; + box-shadow: 0 -6px 24px rgba(0,0,0,.35); + padding: 0 20px max(18px, env(safe-area-inset-bottom)); + transform: translateY(108px); + transition: transform .28s cubic-bezier(.22,.61,.36,1); +} +.standortmenu.offen { transform: translateY(0); } +.standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; } +.standortmenu-griff { width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 0 auto; } +.standortmenu-schliessen { + position: absolute; top: 6px; right: 0; width: 30px; height: 30px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: none; border: none; color: var(--fg3); cursor: pointer; padding: 0; +} +.standortmenu-kopf { margin-top: 6px; } +.standortmenu-distanz { font-size: 12px; color: var(--fg3); } +.standortmenu-name { font-size: 17px; color: var(--fg); margin-top: 2px; } +.standortmenu-adresse { font-size: 13.5px; color: var(--fg2); margin-top: 3px; } +.standortmenu-status { display: flex; align-items: center; gap: 9px; margin-top: 16px; font-size: 14px; color: var(--fg); } +.dot.neutral { background: var(--fg3); } +.standortmenu-werte { display: flex; gap: 30px; margin-top: 16px; } +.standortmenu-aktionen { display: flex; gap: 10px; margin-top: 18px; padding-bottom: 4px; } +.standortmenu-aktionen .aktion { flex: 1; margin-top: 0; } + /* ------------------------------------------------------- Bilderverwaltung */ .bildgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); margin-top: var(--sp-4); } .bildslot { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; }