Standort-Kachel: Live-Fahrzeugposition auf der Übersicht
Neue Kachel oberhalb von "Zuletzt" mit echter Leaflet-Mini-Karte, öffnet per Klick eine Vollbild-Standortansicht (Kartendarstellung wählen, auf Fahrzeug/User zentrieren, beide zeigen) mit einem ausziehbaren myAudi-Stil-Menü: Distanz zum Gerät, Adresse (Reverse- Geocoding via Nominatim), fährt/steht/Letzter-Parkplatz-Status, Tankfüllstand/Reichweite, Route- und Teilen-Aktionen. Backend: STANDORT_TRACKER-Einstellung (device_tracker-Entity) plus _standort()-Veröffentlichung, bewusst leer gelassen bis eine echte GPS-Quelle (FMM003/flespi) angebunden ist - Kachel zeigt bis dahin "kein GPS-Signal". Verifiziert in audi_ha_test mit einer manuell gesetzten Test-Position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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,
|
both narrow (mobile tab bar, full content) and wide (1144px container, grid side-nav,
|
||||||
navigation clicks, settings back-arrow) — screenshots taken, no regressions found.
|
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
|
- [ ] Fix documentation drift (statistics claim, INSTALL variable names, README gaps, obsolete
|
||||||
TODO comment) — text-only changes
|
TODO comment) — text-only changes
|
||||||
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
|
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
|
||||||
|
|||||||
@@ -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"
|
NAECHSTE_INSPEKTION_SENSOR = "sensor.audi_rs_4_avant_inspection_due"
|
||||||
INSPEKTION_STRECKE_SENSOR = "sensor.audi_rs_4_avant_inspection_distance"
|
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
|
# Update-Funktion (Einstellungen -> "Update suchen", siehe
|
||||||
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
|
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
|
||||||
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
|
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
|
||||||
|
|||||||
@@ -77,6 +77,40 @@ def _abs_zahl(wert):
|
|||||||
return None if zahl is None else abs(zahl)
|
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"]
|
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +159,8 @@ def fahrzeugstatus_veroeffentlichen():
|
|||||||
else:
|
else:
|
||||||
gesichert = all([e["ok"] for e in sicherheitscheck])
|
gesichert = all([e["ok"] for e in sicherheitscheck])
|
||||||
|
|
||||||
|
standort = _standort()
|
||||||
|
|
||||||
daten = {
|
daten = {
|
||||||
"km": _zu_zahl(km),
|
"km": _zu_zahl(km),
|
||||||
"tankprozent": _zu_zahl(tankprozent),
|
"tankprozent": _zu_zahl(tankprozent),
|
||||||
@@ -132,6 +168,10 @@ def fahrzeugstatus_veroeffentlichen():
|
|||||||
"batteriespannung": _zu_zahl(batteriespannung),
|
"batteriespannung": _zu_zahl(batteriespannung),
|
||||||
"gesichert": gesichert,
|
"gesichert": gesichert,
|
||||||
"sicherheitscheck": sicherheitscheck,
|
"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
|
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
|
||||||
# App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im
|
# App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im
|
||||||
# Frontend) - die Streckensensoren liefern negative Restkilometer-
|
# Frontend) - die Streckensensoren liefern negative Restkilometer-
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ function profilZuCar(p, status) {
|
|||||||
batteriespannung: status.batteriespannung,
|
batteriespannung: status.batteriespannung,
|
||||||
gesichert: status.gesichert,
|
gesichert: status.gesichert,
|
||||||
sicherheitscheck: status.sicherheitscheck || [],
|
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,
|
oelwechselFaelligTs: status.oelwechsel_faellig_ts,
|
||||||
oelwechselFaelligKm: status.oelwechsel_faellig_km,
|
oelwechselFaelligKm: status.oelwechsel_faellig_km,
|
||||||
inspektionFaelligTs: status.inspektion_faellig_ts,
|
inspektionFaelligTs: status.inspektion_faellig_ts,
|
||||||
@@ -457,6 +461,318 @@ async function initMap() {
|
|||||||
setTimeout(() => MAP && MAP.invalidateSize(), 60);
|
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 `<path d="M5 15.3 6.4 9.9a2 2 0 0 1 1.9-1.5h7.4a2 2 0 0 1 1.9 1.5l1.4 5.4"/>
|
||||||
|
<path d="M4.6 15.3h14.8a.9.9 0 0 1 .9.9v1.1a.9.9 0 0 1-.9.9H4.6a.9.9 0 0 1-.9-.9v-1.1a.9.9 0 0 1 .9-.9Z"/>
|
||||||
|
<path d="M7.4 11.4h9.2"/>`;
|
||||||
|
}
|
||||||
|
function fahrzeugMarkerSVG() {
|
||||||
|
return `<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
${fahrzeugGlyphPfade()}
|
||||||
|
<circle cx="7.6" cy="18.3" r="1" fill="#fff" stroke="none"/>
|
||||||
|
<circle cx="16.4" cy="18.3" r="1" fill="#fff" stroke="none"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG()}</div>`, 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: `<div class="user-pin"></div>`, 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: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG()}</div>`, 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 `<div class="tile flat standort-kachel">
|
||||||
|
<button type="button" class="standort-kachelbtn" data-go="standort:x" aria-label="Standort in Vollbild öffnen">
|
||||||
|
<div class="standort-vorschau" id="tmap"></div>
|
||||||
|
${!standortBekannt() ? `<div class="standort-leer">Kein GPS-Signal vom Fahrzeug</div>` : ""}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="standort-vollbild">
|
||||||
|
<div id="smap" class="standort-karte"></div>
|
||||||
|
<div class="kartensteuerung">
|
||||||
|
<button type="button" data-standort-layer aria-label="Kartendarstellung wählen">
|
||||||
|
<svg viewBox="0 0 20 20" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2 2 6l8 4 8-4-8-4Z"/><path d="m2 10 8 4 8-4"/><path d="m2 14 8 4 8-4"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-standort-fahrzeug aria-label="Auf Fahrzeug zentrieren">
|
||||||
|
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${fahrzeugGlyphPfade()}</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-standort-user aria-label="Auf eigenen Standort zentrieren">
|
||||||
|
<svg viewBox="0 0 20 20" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="10" cy="10" r="2.2" fill="currentColor" stroke="none"/><circle cx="10" cy="10" r="7" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-standort-beide aria-label="Fahrzeug und eigenen Standort gemeinsam zeigen">
|
||||||
|
<svg viewBox="0 0 20 20" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h5v5H3z"/><path d="M12 12h5v5h-5z"/><path d="m8 8 4 4"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="standortmenu" id="standortmenu">
|
||||||
|
<div class="standortmenu-griffzone">
|
||||||
|
<div class="standortmenu-griff"></div>
|
||||||
|
<button type="button" class="standortmenu-schliessen" data-standort-schliessen aria-label="Schließen">
|
||||||
|
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5 5l10 10M15 5 5 15"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="standortmenu-kopf">
|
||||||
|
<span class="standortmenu-distanz" id="standortDistanz">${distanz}</span>
|
||||||
|
<div class="standortmenu-name">${esc(CONFIG.fahrzeugtitel)}</div>
|
||||||
|
<div class="standortmenu-adresse" id="standortAdresse">${esc(adresseText)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="standortmenu-status">
|
||||||
|
${zustand.faehrt ? `<span class="dot"></span><span>Fahrzeug fährt</span>`
|
||||||
|
: zustand.parkplatz ? `<span class="dot neutral"></span><span>Letzter Parkplatz · geparkt seit ${dauerSeitText(zustand.seitTs)}</span>`
|
||||||
|
: `<span class="dot neutral"></span><span>Fahrzeug steht${zustand.seitTs != null ? " · seit " + dauerSeitText(zustand.seitTs) : ""}</span>`}
|
||||||
|
</div>
|
||||||
|
<div class="standortmenu-werte">
|
||||||
|
<div><span class="label">Tankfüllstand</span><div class="wert" style="margin-top:4px">${de(CAR.tankPct)} %</div></div>
|
||||||
|
<div><span class="label">Restreichweite</span><div class="wert" style="margin-top:4px">${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "unbekannt"}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="standortmenu-aktionen">
|
||||||
|
<a class="aktion primaer" style="text-align:center;text-decoration:none;box-sizing:border-box${link ? "" : ";pointer-events:none;opacity:.4"}"${link ? ` href="${link}" target="_blank" rel="noopener"` : ` aria-disabled="true"`}>Route</a>
|
||||||
|
<button type="button" class="aktion" data-standort-teilen ${bekannt ? "" : "disabled"}>Teilen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- Ansichten
|
/* -------------------------------------------------------------- Ansichten
|
||||||
Ab hier praktisch unverändert aus dem Prototyp übernommen. */
|
Ab hier praktisch unverändert aus dem Prototyp übernommen. */
|
||||||
function badge() {
|
function badge() {
|
||||||
@@ -666,6 +982,7 @@ function vHome() {
|
|||||||
return `<div class="tile"><span class="label">${artLabel}</span>
|
return `<div class="tile"><span class="label">${artLabel}</span>
|
||||||
<div class="fig" style="font-size:29px;margin-top:14px">${teileHU.join("/ ")}</div></div>`; })()}
|
<div class="fig" style="font-size:29px;margin-top:14px">${teileHU.join("/ ")}</div></div>`; })()}
|
||||||
</div>
|
</div>
|
||||||
|
${standortKachel()}
|
||||||
${teaser()}`;
|
${teaser()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2143,6 +2460,7 @@ const ZURUECK = {
|
|||||||
/* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren
|
/* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren
|
||||||
Zurück-Pfeil. */
|
Zurück-Pfeil. */
|
||||||
sicherheit: "home",
|
sicherheit: "home",
|
||||||
|
standort: "home",
|
||||||
};
|
};
|
||||||
const ICON = {
|
const ICON = {
|
||||||
sonne: '<circle cx="10" cy="10" r="4"/><path d="M10 1v2M10 17v2M1 10h2M17 10h2M3.9 3.9l1.4 1.4M14.7 14.7l1.4 1.4M16.1 3.9l-1.4 1.4M5.3 14.7l-1.4 1.4"/>',
|
sonne: '<circle cx="10" cy="10" r="4"/><path d="M10 1v2M10 17v2M1 10h2M17 10h2M3.9 3.9l1.4 1.4M14.7 14.7l1.4 1.4M16.1 3.9l-1.4 1.4M5.3 14.7l-1.4 1.4"/>',
|
||||||
@@ -2220,6 +2538,7 @@ function render() {
|
|||||||
else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); }
|
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 === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); }
|
||||||
else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); }
|
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];
|
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](); }
|
v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); }
|
||||||
$("#eyebrow").textContent = head[0]; $("#title").textContent = head[1];
|
$("#eyebrow").textContent = head[0]; $("#title").textContent = head[1];
|
||||||
@@ -2242,6 +2561,10 @@ function render() {
|
|||||||
letzteAnsicht = route.name + "|" + (route.id || "");
|
letzteAnsicht = route.name + "|" + (route.id || "");
|
||||||
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
|
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
|
||||||
if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer
|
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));
|
ROOT.querySelectorAll(".tab").forEach((el) => el.classList.toggle("on", el.dataset.tab === route.name));
|
||||||
}
|
}
|
||||||
const go = (name, id) => {
|
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; }
|
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]");
|
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; }
|
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]");
|
const g = e.target.closest("[data-go]");
|
||||||
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
||||||
});
|
});
|
||||||
@@ -3126,6 +3457,7 @@ class AudiDashboardPanel extends HTMLElement {
|
|||||||
|
|
||||||
ereignisseVerdrahten();
|
ereignisseVerdrahten();
|
||||||
randwischenVerdrahten();
|
randwischenVerdrahten();
|
||||||
|
standortMenuVerdrahten();
|
||||||
themeIcon();
|
themeIcon();
|
||||||
setInterval(standAlterTicken, 1000);
|
setInterval(standAlterTicken, 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version": 1786473562}
|
{"version": 1786524729}
|
||||||
|
|||||||
@@ -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 { border-radius: var(--r-tile); overflow: hidden; height: 210px; background: var(--tile); }
|
||||||
.mapbox #map { width: 100%; height: 100%; }
|
.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 */
|
/* ------------------------------------------------------- Bilderverwaltung */
|
||||||
.bildgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); margin-top: var(--sp-4); }
|
.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; }
|
.bildslot { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user