Einzelbeleg: CI poi pin from the receipt address, plus a centre button

The station marker was a red circleMarker. It is now the CI poi pin
(poi-l/poi-s via tankstellenMarkerSVG) - the plain pin rather than poi-car, so
the vehicle and the station stay distinguishable on a map.

While testing it turned out nothing in the backend ever writes station_lat /
station_lon, so that marker had never been reachable in practice. The receipt
address is now geocoded in the frontend via Nominatim /search
(adresseAufloesen, the forward counterpart to the existing reverse lookup),
with results cached in localStorage - negative ones too, since a station
address does not move and Nominatim's terms ask for caching. Verified live:
"Shell, SÖLDEN" resolves to 46.9756 / 11.0111.

The map card gained a centre button (CI.gps, which is byte-identical to the
delivered gps-s.svg), disabled until the coordinates arrive. .mapbox needed
position:relative so the control anchors to the card instead of an ancestor.
Only in the Einzelbeleg, as requested - the Standort fullscreen already has
its own centre controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:15:20 +02:00
parent 484f2ffe6d
commit 899b770cfa
7 changed files with 292 additions and 10 deletions
+32
View File
@@ -804,6 +804,38 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
un-ignoring. **Keep it in sync whenever `pyscript/` or `www/` changes** — that was already the
working rule, but it is now visible in the repo when it is forgotten.
Deployed to `audi_ha_test` (version `1786766000`).
- [x] Fifth round the same day (2026-08-16) — one-click installer and the Einzelbeleg map:
(1) **`installationspaket/Installieren.cmd` + `install.ps1`** automate steps 13 of
`ANLEITUNG.md`. The `.cmd` wrapper exists because double-clicking a `.ps1` opens it in an
editor and the execution policy blocks it; the bypass is per-call, the machine policy is not
touched. Safe to re-run: never overwrites `fahrzeugprofil.json`, `fahrten.jsonl`,
`tankvorgaenge.jsonl`, `entitaeten.json`, `ha_token.txt`; backs `configuration.yaml` up to
`.bak` and replaces its own marker-delimited block instead of appending twice; refuses to
touch the file at all when `pyscript:`/`panel_custom:` are already there from another source
(duplicate top-level keys are invalid YAML — HA would not start); aborts before writing if
the target has no `configuration.yaml`. `-Pruefen` is a dry run. **`install.ps1` is stored
UTF-8 *with* BOM on purpose** — PowerShell 5.1 reads scripts as ANSI otherwise and mangles
every umlaut; the files it *writes* stay BOM-less (YAML). Verified against a fake config
tree: fresh install, re-run idempotency, data preservation, foreign-key refusal,
wrong-directory abort, and the merged YAML parses.
(2) **Tankstellenposition im Einzelbeleg.** The red `circleMarker` is now the CI `poi` pin
(`CI.poiL`/`CI.poiS` from `poi-l.svg`/`poi-s.svg`, via `tankstellenMarkerSVG()`) — deliberately
the plain pin, not `poi-car`, so vehicle and station stay distinguishable.
**Found while testing: nothing in the backend ever writes `station_lat`/`station_lon`**, so
this marker had never actually been reachable. Fixed by geocoding the receipt address in the
frontend (`adresseAufloesen()`, Nominatim `/search` — the forward counterpart to the existing
`standortAdresseAufloesen()`), with results cached in `localStorage` (`audi_dashboard_geocache`)
including negative results, since a station address does not move and Nominatim's terms ask
for caching. Confirmed live: `Shell, SÖLDEN` → 46.9756/11.0111.
(3) **Zentrieren-Knopf** in the Einzelbeleg map card (`mapBoxMitZentrieren()`, `CI.gps`
byte-identical to the delivered `gps-s.svg`), disabled until the coordinates arrive. `.mapbox`
gained `position:relative` so the control anchors to the card. Only there, per explicit
request — the Standort fullscreen already has its own centre controls.
Deployed to `audi_ha_test` (version `1786769000`).
Note for testing map behaviour in this app: `initMap()` runs on **every** `render()`, and the
app re-renders on each backend push (~30 s), rebuilding the map and re-centring it. Panning
the map from the console and checking later therefore proves nothing — spy on
`L.Map.prototype.flyTo` instead.
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
@@ -386,6 +386,8 @@ function ring(pct) {
/* ------------------------------------------------------------------ Karte
Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */
let MAP = null, TILE = null, LEAFLET_LADEND = null;
// Zielpunkt des Zentrieren-Knopfs im Einzelbeleg (siehe mapBoxMitZentrieren).
let BELEG_STATION_POS = null;
/* 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
@@ -441,6 +443,21 @@ function leafletLaden() {
function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; }
/* Wie mapBox(), aber mit Zentrieren-Knopf - nur im Einzelbeleg. Der Knopf
holt die Tankstelle zurück in die Mitte, nachdem man auf der Karte
herumgezogen hat. Er sitzt im .mapbox (position:relative), damit er über
der Karte schwebt, und ist bis zum Eintreffen der Koordinaten deaktiviert -
die kommen bei Belegen ohne station_lat erst aus dem Nominatim-Abruf
(siehe adresseAufloesen). */
function mapBoxMitZentrieren() {
return `<div class="mapbox">
<div id="map"></div>
<div class="kartensteuerung kartensteuerung-klein">
<button type="button" id="belegZentrieren" aria-label="Auf die Tankstelle zentrieren" disabled>${ciSVG(CI.gps, 20)}</button>
</div>
</div>`;
}
/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte):
die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest
hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen
@@ -487,10 +504,21 @@ async function initMap() {
MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke)
}
} else if (route.name === "fill") {
BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch
const f = FILLS.find((x) => x.tank_id === route.id);
if (f && f.station_lat != null) {
MAP.setView([f.station_lat, f.station_lon], 14);
window.L.circleMarker([f.station_lat, f.station_lon], { radius: 7, color: rot, weight: 2, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP).bindPopup((f.station_name || "") + "<br>" + (f.station_address || ""));
tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f);
} else if (f && (f.station_address || f.station_name)) {
/* Ohne Koordinaten die Anschrift vom Beleg nachschlagen. Bis die Antwort
da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte
inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen
prüfen, ob wir noch auf demselben Beleg sind. */
MAP.setView(kartenMittelpunkt(), 11);
const karte = MAP, belegId = route.id;
adresseAufloesen(f.station_address || f.station_name).then((pos) => {
if (!pos || MAP !== karte || route.name !== "fill" || route.id !== belegId) return;
tankstelleAufKarteZeigen(karte, pos, f);
});
} else {
MAP.setView(kartenMittelpunkt(), 11);
}
@@ -535,6 +563,15 @@ function fahrzeugMarkerSVG(px = 34) {
return `<svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true">${gross ? CI.poiCarL : CI.poiCar}</svg>`;
}
/* Tankstellenposition im Einzelbeleg - dieselbe Nadelform wie beim Fahrzeug,
nur ohne Auto-Glyph (CI.poiL/poiS), damit beide Marker sofort auseinander-
zuhalten sind. Spitze unten wie bei poi-car, gleiche Ankerrechnung. */
function tankstellenMarkerSVG(px = 32) {
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.poiL : CI.poiS}</svg>`;
}
/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist
(siehe fahrterkennung.py), sonst "steht". Ohne echtes Bewegungssignal vom
Fahrzeug (siehe AGENTS.md, FMM003/flespi offen) ist das die beste Näherung
@@ -641,6 +678,76 @@ function standortAdresseLaden() {
});
}
/* Gegenrichtung zu standortAdresseAufloesen: Adresse -> Koordinaten, für die
Tankstellennadel im Einzelbeleg. Nötig, weil der Beleg nur eine Anschrift
trägt und nichts im Backend station_lat/station_lon je befüllt - ohne diesen
Schritt bliebe die Karte dort dauerhaft ohne Nadel.
Ergebnisse landen in localStorage statt nur im RAM: eine Tankstellenadresse
ändert ihre Lage nicht, und der Einzelbeleg wird oft genug erneut geöffnet,
dass ein Cache nur für die laufende Sitzung Nominatim unnötig oft anfassen
würde (deren Nutzungsbedingungen bitten ausdrücklich darum, Ergebnisse zu
cachen). Auch ein Fehlschlag wird vermerkt - als null -, damit eine Adresse,
die Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird. */
const GEO_CACHE_KEY = "audi_dashboard_geocache";
let GEO_CACHE = null;
function geoCacheLesen() {
if (GEO_CACHE) return GEO_CACHE;
try { GEO_CACHE = JSON.parse(localStorage.getItem(GEO_CACHE_KEY)) || {}; }
catch (e) { GEO_CACHE = {}; }
return GEO_CACHE;
}
function geoCacheSchreiben(schluessel, wert) {
const cache = geoCacheLesen();
cache[schluessel] = wert;
try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); }
catch (e) { /* voller/gesperrter Speicher: Cache bleibt dann RAM-only */ }
}
async function adresseAufloesen(adresse) {
const suche = (adresse || "").trim();
if (!suche) return null;
const cache = geoCacheLesen();
if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche];
let treffer = null;
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
{ headers: { "Accept-Language": "de" } }
);
const daten = await res.json();
if (Array.isArray(daten) && daten.length) {
treffer = { lat: parseFloat(daten[0].lat), lon: parseFloat(daten[0].lon) };
}
} catch (e) {
// Netzfehler ist kein "Adresse gibt es nicht" - nichts merken, damit der
// nächste Aufruf es erneut versucht (vgl. standortAdresseAufloesen).
return null;
}
geoCacheSchreiben(suche, treffer);
return treffer;
}
/* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von
initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon
steht. */
function tankstelleAufKarteZeigen(karte, pos, f) {
if (!karte || !window.L || !pos) return;
karte.setView([pos.lat, pos.lon], 14);
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${tankstellenMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 31] });
window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte)
.bindPopup((f.station_name || "") + "<br>" + (f.station_address || ""));
// Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
// Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe
// Aufrufstelle in initMap).
BELEG_STATION_POS = pos;
const knopf = ROOT && ROOT.getElementById("belegZentrieren");
if (knopf) knopf.disabled = false;
}
function belegAufTankstelleZentrieren() {
if (!MAP || !BELEG_STATION_POS) return;
MAP.flyTo([BELEG_STATION_POS.lat, BELEG_STATION_POS.lon], Math.max(MAP.getZoom(), 14));
}
function standortKartenlink() {
return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null;
}
@@ -814,6 +921,12 @@ const CI = {
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(). */
/* poi-l/poi-s aus dem CI-Satz: neutrale Kartennadel mit Kreis, fuer die
Tankstellenposition im Einzelbeleg (poi-car bleibt dem Fahrzeug
vorbehalten, sonst waeren beide Nadeln auf einer Karte nicht zu
unterscheiden). Siehe tankstellenMarkerSVG(). */
poiL: '<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.01M16 17.5a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0m7.5-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13"/>',
poiS: '<path fill="currentColor" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5m7.5 14-.41.28.41.6.41-.6zm0-.89a92 92 0 0 0 3.07-4.9c.87-1.5 1.73-3.1 2.38-4.58A10.4 10.4 0 0 0 18 8.5C18 4.88 15.12 2 11.5 2A6.46 6.46 0 0 0 5 8.5c0 .86.4 2.14 1.05 3.63a48 48 0 0 0 2.38 4.58 91 91 0 0 0 3.07 4.9M8 8.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0M11.5 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5"/>',
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"/>',
@@ -2735,7 +2848,7 @@ function vFill(id) {
}
return `
${mapBox()}
${mapBoxMitZentrieren()}
<div class="tile flat" style="margin:18px 0 14px">
<span class="label">Getankt</span>
<div class="fig" style="font-size:52px;margin-top:10px">${f.liters != null ? de(f.liters, 2) : ""}<span class="unit">l</span></div>
@@ -3669,6 +3782,9 @@ function ereignisseVerdrahten() {
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; }
// Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein
// render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren).
if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; }
const g = e.target.closest("[data-go]");
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
});
@@ -1 +1 @@
{"version": 1786766000}
{"version": 1786769000}
@@ -688,7 +688,10 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.swipe-wrap.swiped .swipe-content { transform: translateX(-84px); }
/* -------------------------------------------------------------- Karte */
.mapbox { border-radius: var(--r-tile); overflow: hidden; height: 210px; background: var(--tile); }
/* position:relative, damit der Zentrieren-Knopf im Einzelbeleg (absolut
positioniert, siehe .kartensteuerung-klein) sich an dieser Kachel ausrichtet
und nicht am naechsten Vorfahren weiter oben. */
.mapbox { position: relative; border-radius: var(--r-tile); overflow: hidden; height: 210px; background: var(--tile); }
.mapbox #map { width: 100%; height: 100%; }
/* --------------------------------------------------------- Standort
@@ -757,6 +760,12 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
}
.kartensteuerung button:active { transform: scale(.94); }
:host([data-theme="tag"]) .kartensteuerung button:active { background: #fff; }
/* Variante in der Belegkarte: die Karte ist dort nur 210px hoch, ein 48er-
Knopf mit 16px Abstand wuerde ein Viertel davon verdecken. */
.kartensteuerung-klein { top: 10px; right: 10px; }
.kartensteuerung-klein button { width: 36px; height: 36px; }
.kartensteuerung-klein button:disabled { opacity: .45; cursor: default; }
.kartensteuerung-klein button:disabled:active { transform: none; }
/* Sichtbarer Anteil im geschlossenen Zustand (STANDORT_PEEK_PX im Frontend) -
beide Werte müssen zusammenpassen, siehe Kommentar dort. Der Rest des
+119 -3
View File
@@ -386,6 +386,8 @@ function ring(pct) {
/* ------------------------------------------------------------------ Karte
Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */
let MAP = null, TILE = null, LEAFLET_LADEND = null;
// Zielpunkt des Zentrieren-Knopfs im Einzelbeleg (siehe mapBoxMitZentrieren).
let BELEG_STATION_POS = null;
/* 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
@@ -441,6 +443,21 @@ function leafletLaden() {
function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; }
/* Wie mapBox(), aber mit Zentrieren-Knopf - nur im Einzelbeleg. Der Knopf
holt die Tankstelle zurück in die Mitte, nachdem man auf der Karte
herumgezogen hat. Er sitzt im .mapbox (position:relative), damit er über
der Karte schwebt, und ist bis zum Eintreffen der Koordinaten deaktiviert -
die kommen bei Belegen ohne station_lat erst aus dem Nominatim-Abruf
(siehe adresseAufloesen). */
function mapBoxMitZentrieren() {
return `<div class="mapbox">
<div id="map"></div>
<div class="kartensteuerung kartensteuerung-klein">
<button type="button" id="belegZentrieren" aria-label="Auf die Tankstelle zentrieren" disabled>${ciSVG(CI.gps, 20)}</button>
</div>
</div>`;
}
/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte):
die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest
hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen
@@ -487,10 +504,21 @@ async function initMap() {
MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke)
}
} else if (route.name === "fill") {
BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch
const f = FILLS.find((x) => x.tank_id === route.id);
if (f && f.station_lat != null) {
MAP.setView([f.station_lat, f.station_lon], 14);
window.L.circleMarker([f.station_lat, f.station_lon], { radius: 7, color: rot, weight: 2, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP).bindPopup((f.station_name || "") + "<br>" + (f.station_address || ""));
tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f);
} else if (f && (f.station_address || f.station_name)) {
/* Ohne Koordinaten die Anschrift vom Beleg nachschlagen. Bis die Antwort
da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte
inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen
prüfen, ob wir noch auf demselben Beleg sind. */
MAP.setView(kartenMittelpunkt(), 11);
const karte = MAP, belegId = route.id;
adresseAufloesen(f.station_address || f.station_name).then((pos) => {
if (!pos || MAP !== karte || route.name !== "fill" || route.id !== belegId) return;
tankstelleAufKarteZeigen(karte, pos, f);
});
} else {
MAP.setView(kartenMittelpunkt(), 11);
}
@@ -535,6 +563,15 @@ function fahrzeugMarkerSVG(px = 34) {
return `<svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true">${gross ? CI.poiCarL : CI.poiCar}</svg>`;
}
/* Tankstellenposition im Einzelbeleg - dieselbe Nadelform wie beim Fahrzeug,
nur ohne Auto-Glyph (CI.poiL/poiS), damit beide Marker sofort auseinander-
zuhalten sind. Spitze unten wie bei poi-car, gleiche Ankerrechnung. */
function tankstellenMarkerSVG(px = 32) {
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.poiL : CI.poiS}</svg>`;
}
/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist
(siehe fahrterkennung.py), sonst "steht". Ohne echtes Bewegungssignal vom
Fahrzeug (siehe AGENTS.md, FMM003/flespi offen) ist das die beste Näherung
@@ -641,6 +678,76 @@ function standortAdresseLaden() {
});
}
/* Gegenrichtung zu standortAdresseAufloesen: Adresse -> Koordinaten, für die
Tankstellennadel im Einzelbeleg. Nötig, weil der Beleg nur eine Anschrift
trägt und nichts im Backend station_lat/station_lon je befüllt - ohne diesen
Schritt bliebe die Karte dort dauerhaft ohne Nadel.
Ergebnisse landen in localStorage statt nur im RAM: eine Tankstellenadresse
ändert ihre Lage nicht, und der Einzelbeleg wird oft genug erneut geöffnet,
dass ein Cache nur für die laufende Sitzung Nominatim unnötig oft anfassen
würde (deren Nutzungsbedingungen bitten ausdrücklich darum, Ergebnisse zu
cachen). Auch ein Fehlschlag wird vermerkt - als null -, damit eine Adresse,
die Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird. */
const GEO_CACHE_KEY = "audi_dashboard_geocache";
let GEO_CACHE = null;
function geoCacheLesen() {
if (GEO_CACHE) return GEO_CACHE;
try { GEO_CACHE = JSON.parse(localStorage.getItem(GEO_CACHE_KEY)) || {}; }
catch (e) { GEO_CACHE = {}; }
return GEO_CACHE;
}
function geoCacheSchreiben(schluessel, wert) {
const cache = geoCacheLesen();
cache[schluessel] = wert;
try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); }
catch (e) { /* voller/gesperrter Speicher: Cache bleibt dann RAM-only */ }
}
async function adresseAufloesen(adresse) {
const suche = (adresse || "").trim();
if (!suche) return null;
const cache = geoCacheLesen();
if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche];
let treffer = null;
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
{ headers: { "Accept-Language": "de" } }
);
const daten = await res.json();
if (Array.isArray(daten) && daten.length) {
treffer = { lat: parseFloat(daten[0].lat), lon: parseFloat(daten[0].lon) };
}
} catch (e) {
// Netzfehler ist kein "Adresse gibt es nicht" - nichts merken, damit der
// nächste Aufruf es erneut versucht (vgl. standortAdresseAufloesen).
return null;
}
geoCacheSchreiben(suche, treffer);
return treffer;
}
/* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von
initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon
steht. */
function tankstelleAufKarteZeigen(karte, pos, f) {
if (!karte || !window.L || !pos) return;
karte.setView([pos.lat, pos.lon], 14);
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${tankstellenMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 31] });
window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte)
.bindPopup((f.station_name || "") + "<br>" + (f.station_address || ""));
// Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
// Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe
// Aufrufstelle in initMap).
BELEG_STATION_POS = pos;
const knopf = ROOT && ROOT.getElementById("belegZentrieren");
if (knopf) knopf.disabled = false;
}
function belegAufTankstelleZentrieren() {
if (!MAP || !BELEG_STATION_POS) return;
MAP.flyTo([BELEG_STATION_POS.lat, BELEG_STATION_POS.lon], Math.max(MAP.getZoom(), 14));
}
function standortKartenlink() {
return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null;
}
@@ -814,6 +921,12 @@ const CI = {
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(). */
/* poi-l/poi-s aus dem CI-Satz: neutrale Kartennadel mit Kreis, fuer die
Tankstellenposition im Einzelbeleg (poi-car bleibt dem Fahrzeug
vorbehalten, sonst waeren beide Nadeln auf einer Karte nicht zu
unterscheiden). Siehe tankstellenMarkerSVG(). */
poiL: '<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.01M16 17.5a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0m7.5-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13"/>',
poiS: '<path fill="currentColor" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5m7.5 14-.41.28.41.6.41-.6zm0-.89a92 92 0 0 0 3.07-4.9c.87-1.5 1.73-3.1 2.38-4.58A10.4 10.4 0 0 0 18 8.5C18 4.88 15.12 2 11.5 2A6.46 6.46 0 0 0 5 8.5c0 .86.4 2.14 1.05 3.63a48 48 0 0 0 2.38 4.58 91 91 0 0 0 3.07 4.9M8 8.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0M11.5 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5"/>',
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"/>',
@@ -2735,7 +2848,7 @@ function vFill(id) {
}
return `
${mapBox()}
${mapBoxMitZentrieren()}
<div class="tile flat" style="margin:18px 0 14px">
<span class="label">Getankt</span>
<div class="fig" style="font-size:52px;margin-top:10px">${f.liters != null ? de(f.liters, 2) : ""}<span class="unit">l</span></div>
@@ -3669,6 +3782,9 @@ function ereignisseVerdrahten() {
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; }
// Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein
// render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren).
if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; }
const g = e.target.closest("[data-go]");
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
});
@@ -1 +1 @@
{"version": 1786766000}
{"version": 1786769000}
+10 -1
View File
@@ -688,7 +688,10 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.swipe-wrap.swiped .swipe-content { transform: translateX(-84px); }
/* -------------------------------------------------------------- Karte */
.mapbox { border-radius: var(--r-tile); overflow: hidden; height: 210px; background: var(--tile); }
/* position:relative, damit der Zentrieren-Knopf im Einzelbeleg (absolut
positioniert, siehe .kartensteuerung-klein) sich an dieser Kachel ausrichtet
und nicht am naechsten Vorfahren weiter oben. */
.mapbox { position: relative; border-radius: var(--r-tile); overflow: hidden; height: 210px; background: var(--tile); }
.mapbox #map { width: 100%; height: 100%; }
/* --------------------------------------------------------- Standort
@@ -757,6 +760,12 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
}
.kartensteuerung button:active { transform: scale(.94); }
:host([data-theme="tag"]) .kartensteuerung button:active { background: #fff; }
/* Variante in der Belegkarte: die Karte ist dort nur 210px hoch, ein 48er-
Knopf mit 16px Abstand wuerde ein Viertel davon verdecken. */
.kartensteuerung-klein { top: 10px; right: 10px; }
.kartensteuerung-klein button { width: 36px; height: 36px; }
.kartensteuerung-klein button:disabled { opacity: .45; cursor: default; }
.kartensteuerung-klein button:disabled:active { transform: none; }
/* Sichtbarer Anteil im geschlossenen Zustand (STANDORT_PEEK_PX im Frontend) -
beide Werte müssen zusammenpassen, siehe Kommentar dort. Der Rest des