1<!DOCTYPE html> 2<html lang="de"> 3<head> 4<meta charset="utf-8"> 5<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 6<meta name="mobile-web-app-capable" content="yes"> 7<meta name="apple-mobile-web-app-capable" content="yes"> 8<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 9<meta name="apple-mobile-web-app-title" content="GasTankstellen"> 10<meta name="theme-color" content="#1D4CA3"> 11<link rel="manifest" href="manifest.json"> 12<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png"> 13<title>GasTankstellen</title> 14
15<script src="https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js?safari-cache-fix=1786264102" crossorigin></script>
vendor: 1 bytes, line 15
15
16<script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js?safari-cache-fix=1786264102" crossorigin></script>
16 17 18 19<style> 20*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 21 22:root { 23 --blue: #1D4CA3; 24 --blue2: #163a80; 25 --green: #27ae60; 26 --red: #e74c3c; 27 --orange: #e67e22; 28 --bg: #f4f6fb; 29 --card: #ffffff; 30 --text: #1a1a2e; 31 --sub: #6b7280; 32 --border: #e5e7eb; 33 --safe-t: env(safe-area-inset-top, 0px); 34 --safe-b: env(safe-area-inset-bottom, 0px); 35 color-scheme: light; 36} 37 38/* 27.07.26 User-Wunsch: Dark-Mode fuer die gesamte PWA, per Profil-Umschalter Hell/Dunkel/Auto 39 (siehe applyEffectiveTheme() im Script). "Auto" berechnet in JS aus der Systemeinstellung und 40 setzt data-theme genauso wie eine explizite Wahl - die CSS-Seite kennt daher nur noch das 41 Attribut, keine @media-Abfrage mehr (vermeidet Spezifitaets-Zickzack zwischen Media-Query und 42 manueller Wahl). Die Kern-Tokens (--bg/--card/--text/--sub/--border) werden hier umdefiniert - 43 alle Komponenten, die bereits darauf statt auf hartcodierte Farben setzen, passen sich 44 automatisch an. Blau/Gruen/Rot/Orange (Marken-/Statusfarben) bleiben bewusst gleich, wie bei 45 den meisten Apps mit farbiger App-Bar. Zusaetzlich color-scheme:dark, damit native 46 Formularelemente (Select, Scrollbars) ebenfalls dunkel rendern. */ 47:root[data-theme="dark"] { 48 --bg: #12151c; 49 --card: #1c212b; 50 --text: #eef1f6; 51 --sub: #9aa3b2; 52 --border: #2a3140; 53 color-scheme: dark; 54} 55 56html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); } 57/* 26.08.2026 User-Wunsch: "Zoomen per Pinch-Geste ausserhalb der Karte verhindern" (Nachfrage: 58 "auch in der Liste") - versehentliches Pinch auf Listen/Buttons zoomt sonst die ganze Seite 59 (browsertypisches Pinch-Zoom), was sich bei einer PWA wie ein Bug anfuehlt. 60 touch-action:pan-x/pan-y unterbindet Pinch-Zoom + Doppeltipp-Zoom, ohne normales Scrollen zu 61 blockieren. WICHTIG: touch-action vererbt sich in CSS NICHT automatisch an Kind-Elemente - 62 eine Regel nur auf html/body wirkt daher NUR auf diese beiden Elemente selbst, nicht auf den 63 Inhalt darin (per getComputedStyle() live nachgewiesen: #view-list blieb trotzdem "auto"). 64 Fix: "*"-Selektor erzwingt touch-action:inherit auf jedem Element, wodurch sich der Wert von 65 html tatsaechlich die ganze Kette hinunter durchreicht. Die drei MapKit-Container brauchen 66 dagegen ihre eigene Pinch-Geste (MapKit JS handhabt Zoom selbst per Touch-Events) - dort 67 touch-action:auto, damit der Browser nichts davon abfaengt. */ 68html { touch-action: pan-x pan-y; } 69* { touch-action: inherit; } 70#full-map, #detail-map, #route-map-container { touch-action: auto; } 71 72/* ââ Layout ââ */ 73/* 11.08.2026 User-Report: "als 'zum Homescreen' nimmt die PWA sich nicht den ganzen Raum, erst 74 nach einem Pull-to-Refresh" (hellblauer Rand unten = html/body-Hintergrund schimmert durch, 75 genau in der Hoehe von Safaris Tableiste - User-Beobachtung). Vermutlich ein WebKit-Bug: 76 die Standalone-PWA rendert den allerersten Frame nach Kaltstart noch mit dem Platzverbrauch 77 einer normalen Safari-Tableiste, bevor iOS auf den echten Vollbild-Viewport umschaltet - der 78 von iOS gemeldete sichtbare Bereich ist in diesem Moment selbst falsch, nicht nur die 79 CSS-Berechnung. Versuch mit position:fixed+inset:0 (anstatt 100dvh) behob den Rand NICHT 80 (der falsche Viewport-Wert steckt tiefer), brach aber die Pull-to-Refresh-Geste (User: "PTR 81 geht nicht mehr!") - deshalb zurueckgerollt auf 100dvh. Verbleibt vorerst als bekannte, 82 vermutlich nur durch Apple selbst loesbare iOS-Eigenheit; siehe window.scrollTo()-Nudge im 83 Script als schwacher Mitigationsversuch. */ 84#app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow-x: hidden; } 85 86/* ââ Pull-to-Refresh (03.08.26) ââ */ 87.ptr-indicator { 88 position: fixed; left: 50%; top: 0; width: 34px; height: 34px; margin-left: -17px; 89 transform: translateY(-40px); border-radius: 50%; background: var(--card); 90 box-shadow: 0 2px 8px rgba(0,0,0,.2); display: flex; align-items: center; justify-content: center; 91 z-index: 500; transition: transform .2s ease, opacity .2s ease; opacity: 0; pointer-events: none; 92} 93.ptr-indicator.ptr-visible { opacity: 1; } 94.ptr-indicator svg { width: 18px; height: 18px; color: var(--blue); } 95.ptr-indicator.ptr-spin svg { animation: ptr-rotate .7s linear infinite; } 96@keyframes ptr-rotate { to { transform: rotate(360deg); } } 97 98/* ââ Header ââ */ 99#header { 100 background: var(--blue); 101 color: #fff; 102 padding: 12px 16px 12px; 103 display: flex; align-items: center; gap: 10px; 104 box-shadow: 0 2px 8px rgba(0,0,0,.25); 105 flex-shrink: 0; 106 z-index: 10; 107} 108@media (display-mode: standalone) { 109 #header { padding-top: calc(env(safe-area-inset-top, 0px) + 12px); } 110} 111#header h1 { font-size: 1.1rem; font-weight: 700; flex: 1; } 112#header svg { flex-shrink: 0; } 113 114/* ââ Suche ââ */ 115#search-bar { 116 background: var(--blue2); 117 padding: 10px 16px 12px; 118 display: flex; gap: 8px; align-items: center; 119 flex-shrink: 0; 120} 121#search-input { 122 flex: 1; min-width: 0; border: none; border-radius: 10px; 123 padding: 10px 14px; font-size: 1rem; 124 background: rgba(255,255,255,.15); color: #fff; 125 outline: none; 126} 127#search-input::placeholder { color: rgba(255,255,255,.6); } 128#search-input:focus { background: rgba(255,255,255,.25); } 129#btn-search { 130 background: #fff; color: var(--blue); 131 border: none; border-radius: 10px; 132 padding: 10px 14px; font-size: .9rem; font-weight: 700; 133 cursor: pointer; white-space: nowrap; 134} 135 136/* 18.09.2026 (User: Filter auch in der normalen PWA-Suche) - Filter-Button in der Suchleiste, Panel 137 klappt darunter auf; gleiche Auswahl gilt fuer Ort-/Kennzeichen-/Autobahn-/GPS-Suche. */ 138#btn-search-filter { 139 background: rgba(255,255,255,.15); color: #fff; border: none; border-radius: 10px; 140 padding: 10px 12px; font-size: .9rem; font-weight: 700; cursor: pointer; white-space: nowrap; 141} 142#btn-search-filter.active { background: #f59e0b; } 143#search-filter-panel { 144 display: none; background: var(--blue2); padding: 0 16px 12px; color: #fff; flex-shrink: 0; 145 grid-template-columns: 1fr 1fr; gap: 4px 12px; 146} 147.sf-row { display: flex; align-items: center; gap: 8px; font-size: .85rem; padding: 4px 0; } 148.sf-row input { width: 18px; height: 18px; } 149@media (max-width: 360px) { 150 #btn-search { padding: 10px 10px; } 151 #btn-search .btn-search-label { display: none; } 152} 153/* ââ Gasart-Umschalter (Kopfzeile) ââ */ 154/* 11.09.2026 (User-Wunsch: "rechts oben [...] 'LPG * CNG' [...] genau DAS als Umschalter [...] 155 Platz sparen. Die aktive Gasart soll grösser und grün angezeigt werden") - vormals eine volle 156 Zeile unter der Suchleiste (grosse Pillen-Buttons), jetzt kompakt in der Kopfzeile. Dieselbe 157 #mode-bar/.mode-btn-Struktur wie zuvor (nur restyled) - keine JS-Aenderung noetig.
158 11.09.2026 Folge-Feedback: aktive Gasart noch groesser, inaktive Gasart hellrot statt grau, 159 mittig ein optischer Schalter (Track+Thumb) statt des reinen "·"-Trenners - rein CSS/Markup, 160 Thumb-Position folgt via :has() automatisch der bestehenden .active-Klasse, keine JS-Aenderung. */ 161#mode-bar { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } 162.mode-btn { 163 border: none; background: none; color: #ff8a80; 164 padding: 0; font-size: .8rem; font-weight: 600; cursor: pointer; 165 transition: all .15s; 166} 167.mode-btn.active { color: var(--green); font-size: 1.25rem; font-weight: 800; } 168 169#mode-switch { 170 position: relative; display: inline-block; flex-shrink: 0; 171 width: 22px; height: 12px; border-radius: 6px; background: rgba(255,255,255,.25); 172} 173#mode-switch-thumb { 174 position: absolute; top: 1px; left: 1px; width: 10px; height: 10px; border-radius: 50%; 175 background: var(--green); transition: transform .2s; 176} 177#mode-bar:has(.mode-btn[data-mode="cng"].active) #mode-switch-thumb { transform: translateX(10px); } 178 179/* ââ GPS Button ââ */ 180#btn-gps { 181 position: absolute; /* wird per JS gesetzt */ 182 margin: 0 auto; 183 display: flex; align-items: center; gap: 8px; 184 background: var(--blue); color: #fff; 185 border: none; border-radius: 50px; padding: 14px 24px; 186 font-size: 1rem; font-weight: 700; cursor: pointer; 187 box-shadow: 0 4px 16px rgba(29,76,163,.4); 188 transition: background .15s; 189} 190#btn-gps:active { background: var(--blue2); } 191/* 18.07.26: Radius-Selektor neben "Meinen Standort nutzen" (User-Wunsch) */ 192#gps-radius { 193 border: 1.5px solid var(--blue); border-radius: 50px; padding: 10px 14px; 194 font-size: .95rem; font-weight: 700; color: var(--blue); background: #fff; 195 cursor: pointer; 196} 197 198/* ââ Views ââ */ 199#view-home, #view-list, #view-detail, #view-map { 200 flex: 1; overflow-y: auto; overflow-x: hidden; display: none; flex-direction: column; 201} 202#view-home { display: flex; flex-direction: column; align-items: center; } 203#home-center { text-align: center; padding: 32px 24px; } 204#home-center p { color: var(--sub); margin: 16px 0 24px; font-size: .95rem; line-height: 1.5; } 205 206/* ââ Liste ââ */ 207#list-header { 208 padding: 10px 16px; font-size: .85rem; color: var(--sub); 209 background: var(--bg); border-bottom: 1px solid var(--border); 210 display: flex; justify-content: space-between; align-items: center; 211 flex-shrink: 0; 212} 213#list-header strong { color: var(--text); } 214#btn-list-print { flex-shrink: 0; background: var(--hover, #f0f4f8); border: 1px solid var(--border); 215 border-radius: 8px; width: 28px; height: 28px; font-size: 14px; line-height: 1; cursor: pointer; 216 display: none; align-items: center; justify-content: center; padding: 0; } 217#btn-list-print.show { display: flex; } 218 219.station-card { 220 background: var(--card); border-bottom: 1px solid var(--border); 221 padding: 14px 16px; display: flex; align-items: flex-start; gap: 12px; 222 cursor: pointer; transition: background .1s; 223 -webkit-tap-highlight-color: transparent; 224} 225.station-card:active { background: #f0f4ff; } 226.station-card-bab { background: #fff7e6; border-left: 4px solid #f59e0b; } 227.bab-badge { display:inline-block; background:#f59e0b; color:#fff; font-size:.68rem; font-weight:700; border-radius:4px; padding:1px 6px; margin-top:2px; } 228.bab-ausfahrt { font-size:.78rem; color:#555; margin-top:2px; } 229 230.station-icon { 231 width: 42px; height: 42px; border-radius: 10px; 232 display: flex; align-items: center; justify-content: center; 233 font-size: 1.3rem; flex-shrink: 0; 234} 235 236.station-body { flex: 1; min-width: 0; } 237.station-name { font-weight: 700; font-size: .95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 238.station-addr { color: var(--sub); font-size: .8rem; margin-top: 2px; } 239.station-prices { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; } 240.price-tag { 241 font-size: .8rem; font-weight: 700; padding: 2px 8px; 242 border-radius: 20px; 243} 244.price-lpg { background: #dbeafe; color: #1e40af; } 245.price-cng { background: #d1fae5; color: #065f46; } 246.price-none { background: #f3f4f6; color: var(--sub); } 247.trend-up { color: #dc2626; font-size: .75em; } 248.trend-down { color: #16a34a; font-size: .75em; } 249 250.station-meta { text-align: right; flex-shrink: 0; } 251.dist-badge { font-size: .85rem; font-weight: 700; color: var(--blue); } 252.dist-unit { font-size: .7rem; color: var(--sub); } 253.h24-badge { display:inline-block; margin-top:4px; background:#16a34a; color:#fff; font-size:.65rem; font-weight:700; border-radius:4px; padding:1px 5px; } 254.age-badge { font-size: .7rem; color: var(--sub); margin-top: 2px; } 255 256/* 18.09.2026 (User: "der LKW ist bei #26543 verschwunden" - Icon-only-Zeile im 2-spaltigen 257 #detail-infos-Grid war zu klein/unauffaellig, praktisch unsichtbar; User-Wunsch: "reine Badges 258 für EC/KK und LKW die wenig Platz brauchen... brauchen wir ja dann auch für die Liste") -
259 kompakte Pill-Badges analog zu .h24-badge, wiederverwendet in Detail- UND Listenansicht. */ 260.feat-badges { display: flex; flex-wrap: wrap; gap: 4px; } 261.feat-badge { 262 display: inline-flex; align-items: center; gap: 3px; 263 font-size: .68rem; font-weight: 700; border-radius: 4px; padding: 2px 6px; line-height: 1.4; 264} 265.feat-badge--lkw { background: #e5e7eb; color: #374151; } 266.feat-badge--giro { background: #dbeafe; color: #1d4ca3; } 267.feat-badge--kk { background: #fde2e2; color: #cc0000; } 268 269/* ââ Detail ââ */ 270#view-detail { display: none; } 271#detail-map { width: 100%; height: 220px; flex-shrink: 0; } 272#detail-body { padding: 16px; } 273#detail-name { font-size: 1.2rem; font-weight: 800; } 274#detail-addr { color: var(--sub); margin-top: 4px; font-size: .9rem; } 275 276.detail-prices { 277 display: flex; gap: 10px; margin: 14px 0; 278} 279.detail-price-box { 280 flex: 1; border-radius: 12px; padding: 12px 14px; text-align: center; 281} 282.detail-price-box.lpg { background: #dbeafe; } 283.detail-price-box.cng { background: #d1fae5; } 284.detail-price-box .gas-label { font-size: .75rem; font-weight: 700; color: var(--sub); text-transform: uppercase; } 285.detail-price-box .gas-price { font-size: 1.6rem; font-weight: 800; margin: 4px 0; } 286.detail-price-box.lpg .gas-price { color: #1e40af; } 287.detail-price-box.cng .gas-price { color: #065f46; } 288.detail-price-box .gas-age { font-size: .72rem; color: var(--sub); } 289/* 04.09.2026 (User-Idee: Quadrat gleicher Höhe neben der Preis-Box, Check-Haken zur schnellen 290 Preis-Bestätigung ohne das Preismeldungs-Sheet zu öffnen) - aspect-ratio:1 statt fester Breite, 291 damit die Breite automatisch der (variablen) Höhe der Preis-Box in derselben Flex-Zeile folgt. */ 292.detail-price-confirm-btn { 293 /* 04.09.2026 Nachbesserung (User: "ist zu schmal!") - aspect-ratio allein liess die Breite auf 294 die (oft geringe) Stretch-Hoehe der Preis-Box in dieser Flex-Zeile schrumpfen; min-width 295 erzwingt jetzt ein brauchbares Tap-Ziel unabhaengig davon. */ 296 aspect-ratio: 1 / 1; min-width: 72px; flex: 0 0 auto; border: none; border-radius: 12px; 297 background: #16a34a; display: flex; align-items: center; justify-content: center; 298 cursor: pointer; touch-action: manipulation; 299} 300.detail-price-confirm-btn:active { filter: brightness(.9); } 301 302/* 05.09.2026 (User: "kannst du den Bereich 2-spaltig machen? 7tage & Ãffnungszeiten / Telefon & 303 Entfernung / links & rechts-bündig" - "damit sparen wir zwei Zeilen und es sieht kompakter 304 aus"): alle Fakten (24h/7-Tage/LKW/Ãffnungszeiten/Telefon/Distanz/Autobahn-Info) liegen als 305 gleichartige row()-Elemente vor und werden vom Grid automatisch in Erzeugungsreihenfolge 306 gepaart - kein separates Chip-/Badge-Konzept mehr noetig. Ungerade Gesamtzahl: letzte Zeile 307 bleibt allein in Spalte 1. Spalte 1 linksbuendig (Icon vorn), Spalte 2 rechtsbuendig gespiegelt 308 (Icon hinten, ueber :nth-child(2n)+row-reverse) statt beide Spalten gleich auszurichten. 309 Trennlinien entfallen, da sie in 2 Spalten optisch nicht mehr sauber durchlaufen wuerden. */ 310#detail-infos { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; } 311.detail-info-row { 312 display: flex; align-items: flex-start; gap: 8px; 313 padding: 7px 0; 314 font-size: .9rem; min-width: 0; 315} 316/* 05.09.2026 zweite Nachbesserung (User: "noch immer ist rechts leerer Raum!") - width:100% + 317 justify-content:flex-end blieb sichtbar wirkungslos. justify-self:end ist die eigentlich 318 dafuer vorgesehene Grid-Eigenschaft: schiebt die Zeile (jetzt wieder shrink-to-fit statt 319 erzwungener voller Spaltenbreite) als Ganzes an den rechten Rand IHRER Grid-Zelle. */ 320.detail-info-row:nth-child(2n) { justify-self: end; flex-direction: row-reverse; text-align: right; } 321.detail-info-icon { width: 22px; text-align: center; color: var(--blue); flex-shrink: 0;
321 margin-top: 1px; } 322 323.detail-actions { display: flex; gap: 10px; margin-top: 16px; } 324.action-btn { 325 flex: 1; border: none; border-radius: 12px; padding: 14px; 326 font-size: .9rem; font-weight: 700; cursor: pointer; text-align: center; 327 text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 6px; 328} 329.action-btn.nav { background: var(--blue); color: #fff; } 330.action-btn.info { background: var(--bg); color: var(--blue); border: 2px solid var(--blue); } 331.action-btn.fav { background: var(--bg); color: #e11d48; border: 2px solid #e11d48; flex: 0 0 56px; } 332.action-btn.fav.active { background: #e11d48; color: #fff; } 333 334/* ââ Favoriten (Home) ââ */ 335#fav-section { width: 100%; max-width: 420px; margin: 8px auto 0; text-align: left; } 336#fav-section h3 { font-size: .85rem; color: var(--sub); margin: 0 0 6px 4px; text-transform: uppercase; letter-spacing: .03em; } 337.fav-row { 338 display: flex; align-items: center; gap: 10px; background: #fff; border-radius: 10px; 339 padding: 10px 12px; margin-bottom: 6px; box-shadow: 0 1px 4px rgba(0,0,0,.06); cursor: pointer; 340} 341.fav-row-body { flex: 1; min-width: 0; text-align: left; } 342.fav-row-name { font-size: .88rem; font-weight: 700; color: #222; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 343.fav-row-addr { font-size: .76rem; color: var(--sub); } 344.fav-row-prices { margin-top: 4px; display: flex; gap: 6px; flex-wrap: wrap; } 345.fav-row-prices .price-tag { font-size: .68rem; padding: 2px 6px; } 346.fav-row-remove { flex-shrink: 0; background: none; border: none; color: #c0392b; font-size: 1.1rem; cursor: pointer; padding: 4px 6px; } 347.fav-tabs { width: 100%; max-width: 420px; margin: 18px auto 0; text-align: center; } 348.fav-tab { display: inline-flex; align-items: center; justify-content: center; 349 width: 48px; height: 48px; font-size: 1.25rem; background: var(--card); 350 border: 2px solid var(--border); border-radius: 10px; cursor: pointer; 351 font-family: inherit; color: var(--sub); transition: all .15s; } 352.fav-tab + .fav-tab { margin-left: 8px; } 353.fav-tab[data-tab="local"].active { border-color: #e11d48; color: #e11d48; background: #fff1f2; } 354.fav-tab[data-tab="profile"].active { border-color: #f59e0b; color: #f59e0b; background: #fffbeb; } 355.action-btn.fav-profile { background: var(--bg); color: #f59e0b; border: 2px solid #f59e0b; flex: 0 0 56px; } 356.action-btn.fav-profile.active { background: #f59e0b; color: #fff; } 357 358/* ââ Preismeldungs-Sheet ââ */ 359#price-sheet-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 1000; } 360#price-sheet-overlay.open { display: block; } 361 362/* 05.09.2026 (User-Idee: nach erfolgreichem Quick-Jump-Update statt Fenster-Schliessen (auf iOS 363 technisch unmoeglich) wieder den gruenen q.php-Button zeigen, damit direkt die naechste 364 Tankstelle drankommt) - optisch identisch zu mobile/q.php's .go-Button, aber ohne Anleitung. */ 365/* :not([hidden]) statt display auf dem ID-Selektor selbst - eine ID hat hoehere Spezifitaet als 366 die UA-Regel "[hidden]{display:none}" und wuerde sie sonst ausser Kraft setzen, das Overlay 367 waere trotz hidden-Attribut permanent sichtbar (per Browser-Test am 05.09.2026 gefunden). */ 368#quickjump-again-overlay { position: fixed; inset: 0; z-index: 2000; background: #15803d; } 369#quickjump-again-overlay:not([hidden]) { display: flex; align-items: center; justify-content: center; } 370#qj-again-btn { display: flex; flex-direction: column; align-items: center; gap: 14px; 371 color: #fff; background: rgba(255,255,255,.08); border: none; border-radius: 20px; 372 padding: 32px 40px; font-family: inherit; } 373#qj-again-btn svg { width: 56px; height: 56px; } 374#qj-again-btn span { font-size: 1.15rem; font-weight: 700; } 375#price-sheet { position: absolute; left: 0; right: 0; bottom: 0; 376 background: var(--card); border-radius: 18px 18px 0 0; overflow: hidden; } 377.price-sheet-btn { display: block; width: 100%; padding: 16px 18px; 378 background: none; border: none; border-bottom: 1px solid var(--border); 379 font-size: 1rem; font-family: inherit; color: var(--blue); cursor: pointer; text-align: center; } 380.price-sheet-btn.danger { color: #dc2626; } 381.price-sheet-btn.cancel { color: var(--text); font-weight: 600; } 382.price-sheet-btn:disabled { opacity: .45; cursor: default; } 383/* 04.09.2026 (User-Wunsch: "die Preiseingabe per Eingabe-Feld ist so wie es ist unterwegs 384 nicht gut gemacht" - reines Zahlenfeld+Tastatur ist mit einer Hand/Handschuhen am Automaten 385 unhandlich): Stepper mit grossen Tap-Zielen um das Preisfeld herum, Direkteingabe per Tippen 386 ins Feld bleibt als Fallback erhalten (z.B. fuer groessere Preisspruenge). */ 387#ps-price-input-row { display: none; padding: 12px 18px; border-bottom: 1px solid var(--border); 388 flex-direction: column; gap: 10px; } 389#ps-price-input-row.open { display: flex; } 390.ps-stepper { display: flex; align-items: center; gap: 10px; } 391.ps-step-btn { flex: 0 0 52px; height: 52px; border-radius: 12px; border: 2px solid var(--border); 392 background: var(--bg); color: var(--blue); font-size: 1.6rem; font-weight: 700; line-height: 1; 393 font-family: inherit; cursor: pointer; user-select: none; touch-action: manipulation; } 394.ps-step-btn:active { background: var(--blue); color: #fff; border-color: var(--blue); } 395#ps-price-input { flex: 1; min-width: 0; padding: 10px 12px; border: 2px solid var(--border); 396 border-radius: 10px; font-size: 1.4rem; font-family: inherit; text-align: center; 397 background: var(--bg); color: var(--text); } 398#ps-price-input:focus { outline: none; border-color: var(--blue); } 399#ps-price-submit { padding: 12px 16px; background: var(--blue); color: #fff; width: 100%; 400 border: none; border-radius: 10px; font-size: 1rem; font-family: inherit; cursor: pointer; } 401#ps-msg-row { display: none; padding: 12px 18px; border-bottom: 1px solid var(--border); 402 flex-direction: column; gap: 8px; } 403#ps-msg-row.open { display: flex; } 404#ps-msg-input { padding: 10px 12px; border: 2px solid var(--border); border-radius: 10px; 405 font-size: .95rem; font-family: inherit; background: var(--bg); color: var(--text); 406 resize: none; rows: 3; } 407#ps-msg-input:focus { outline: none; border-color: var(--blue); } 408#ps-msg-submit { padding: 10px 16px; background: var(--blue); color: #fff; 409 border: none; border-radius: 10px; font-size: 1rem; font-family: inherit; cursor: pointer; align-self: flex-end; } 410 411/* ââ Karten-View ââ */ 412#view-map { position: relative; } 413#full-map { width: 100%; flex: 1; } 414/* 18.07.26: Radius-Stufen direkt auf der Karte umschalten (User-Wunsch) */ 415#map-radius-ctrl {
416 /* 11.08.2026 User-Wunsch: etwas tiefer (naeher am unteren Rand), zweite Nachbesserung -15px */ 417 position: absolute; bottom: calc(-5px + var(--safe-b)); left: 50%; transform: translateX(-50%); 418 display: flex; align-items: center; gap: 2px; 419 background: var(--blue); color: #fff; border-radius: 50px; 420 box-shadow: 0 4px 12px rgba(0,0,0,.3); 421 z-index: 500; 422} 423#map-radius-ctrl button { 424 background: none; border: none; color: #fff; font-size: 1.3rem; font-weight: 700; 425 width: 40px; height: 40px; cursor: pointer; line-height: 1; 426} 427#map-radius-ctrl button:disabled { opacity: .35; cursor: default; } 428#map-radius-label { font-size: .9rem; font-weight: 700; padding: 0 4px; min-width: 52px; text-align: center; } 429/* 11.08.2026 User-Idee: optionaler "GroÃ"-Schalter fuer die Karte (bessere Lesbarkeit bei 430 schlechten Sichtverhaeltnissen, dafuer weniger Tankstellen gleichzeitig im Bild) - siehe 431 toggleMapLargeMode() unten. */ 432#map-large-toggle { 433 position: absolute; top: calc(10px + var(--safe-t)); right: 10px; z-index: 500; 434 background: var(--blue); color: #fff; border: none; border-radius: 50%; 435 width: 40px; height: 40px; font-size: 1.15rem; cursor: pointer; 436 box-shadow: 0 4px 12px rgba(0,0,0,.3); 437} 438#map-large-toggle.active { background: #e67e22; } 439/* 11.08.2026 User-Idee: optionaler "Auto"-Modus fuer die Karte - aktualisiert Standort+Karte 440 automatisch alle 5 Minuten (fuers Fahren, kein manuelles GPS-Antippen noetig). Eigener 441 Schalter statt drittem Zustand des GroÃ-Schalters (User-Wunsch: "als eigener Schalter!"), 442 direkt darunter positioniert. Siehe mapAutoRefresh()/startMapAutoTracking() im Script. */ 443#map-auto-toggle { 444 position: absolute; top: calc(58px + var(--safe-t)); right: 10px; z-index: 500; 445 background: var(--blue); color: #fff; border: none; border-radius: 50%; 446 width: 40px; height: 40px; font-size: 1.15rem; cursor: pointer; 447 box-shadow: 0 4px 12px rgba(0,0,0,.3); 448} 449#map-auto-toggle.active { background: #27ae60; } 450 451/* ââ Bottom Nav ââ */ 452#bottom-nav { 453 display: flex; background: #fff; border-top: 1px solid var(--border); 454 padding-bottom: var(--safe-b); 455 flex-shrink: 0; 456} 457.nav-btn { 458 flex: 1; min-width: 0; border: none; background: none; padding: 10px 2px 8px; 459 display: flex; flex-direction: column; align-items: center; gap: 3px; 460 color: var(--sub); font-size: clamp(.56rem, 2.5vw, .68rem); font-weight: 600; cursor: pointer; 461 transition: color .15s; 462 position: relative; 463 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 464 /* 11.08.2026 User-Wunsch: verhindert, dass ein langer Fingerdruck auf einen Nav-Button 465 (auch auf denen OHNE eigene Long-Press-Funktion) die native Text-Markierung/das 466 Callout-Menü des Browsers ausloest - unabhaengig vom JS-Touch-Timer, betrifft alle Buttons. */ 467 user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; 468} 469/* 28.08.2026 (User-Fund: "PWA-Interface minimal zu breit, scrollt horizontal" - seit dem 470 Tankbuch-Button sind es 7 Buttons in der Fussleiste. Flex-Kinder schrumpfen per Default 471 NICHT unter ihre Inhaltsbreite (min-width:auto), ein einzelnes langes Wort wie "Tankbuch" 472 erzwingt dadurch eine Mindestbreite, die auf schmalen Screens die gesamte Seite in die Breite 473 reisst. Fix: min-width:0 oben erlaubt das Schrumpfen ueberhaupt erst, clamp() unten skaliert 474 Icon+Schrift fliessend mit der Viewport-Breite ("passend zur maximalen Display-Breite 475 gezoomt") statt hart abzuschneiden. */ 476.nav-btn svg { width: clamp(18px, 5.5vw, 22px); height: clamp(18px, 5.5vw, 22px); flex-shrink: 0; } 477.nav-btn.active { color: var(--blue); } 478/* 28.08.2026 (User-Wunsch: "Tankbuch ausgrauen, wenn nicht angemeldet") - rein visueller Hinweis, 479 der eigentliche Zugriffsschutz laeuft weiterhin serverseitig ueber mobile/api/tankbuch.php. */ 480.nav-btn.nav-btn-locked { opacity: .4; } 481/* 11.08.2026 User-Idee: kleiner Punkt markiert Buttons mit Long-Press-Funktion (Umkreissuche 482 am GPS-Standort) - bewusst NICHT var(--blue) (schon fuer den aktiven Tab reserviert, sonst 483 verwechselbar) und ein Punkt statt eines vollen Balkens (dezent, kein Notification-Look). */ 484.nav-btn.has-longpress::after {
485 content: ''; 486 position: absolute; 487 top: 6px; 488 left: calc(50% + 9px); 489 width: 5px; height: 5px; 490 border-radius: 50%; 491 background: #e67e22; 492} 493 494/* ââ Loader ââ */ 495#loader { 496 display: none; position: fixed; inset: 0; background: rgba(255,255,255,.7); 497 justify-content: center; align-items: center; z-index: 100; 498} 499#loader.show { display: flex; } 500.spinner { 501 width: 40px; height: 40px; border: 4px solid var(--border); 502 border-top-color: var(--blue); border-radius: 50%; animation: spin .8s linear infinite; 503} 504@keyframes spin { to { transform: rotate(360deg); } } 505 506/* ââ Toast ââ */ 507/* 10.07.26: von unten (bottom) nach oben (top) verlegt - unten wurde die Meldung bei 508 geoeffneter virtueller Tastatur (z.B. waehrend der Eingabe im Suchfeld) verdeckt. */ 509/* 26.09.2026 (User-Fund: "das Feedback für erfolgreichen Preis bestätigen ist schlecht zu sehen! 510 Den Text viel gröÃer und mittig", "doch den Toast global vergröÃern!", dann nach dem ersten 511 Versuch: "viel zu klein und nicht in der Mitte!") - jetzt wirklich BILDSCHIRMMITTE 512 (top:50%/left:50%, beide Achsen zentriert) statt nur oben horizontal zentriert, deutlich 513 groessere Schrift. Bleibt trotzdem unproblematisch mit der Tastatur (10.07.26-Grund oben): 514 die Bildschirmmitte liegt bei geoeffneter virtueller Tastatur normalerweise noch darueber. */ 515#toast { 516 display: none; position: fixed; top: 50%; left: 50%; 517 transform: translate(-50%, -50%); background: #333; color: #fff; 518 padding: 26px 34px; border-radius: 20px; font-size: 1.6rem; font-weight: 800; z-index: 500; 519 white-space: normal; max-width: 85vw; text-align: center; line-height: 1.3; 520 box-shadow: 0 8px 30px rgba(0,0,0,.35); 521} 522/* 26.09.2026 (User-Wunsch: "grüner Hintergrund (da erfolgreich)" / "roter Hintergrund, wenn nicht 523 erfolgreich") - optionale Erfolg/Fehler-Faerbung ueber showToast()s dritten Parameter. Bewusst 524 NICHT der Default (bliebe sonst neutral dunkel wie bisher) - nur an der Preis-Rueckmeldung 525 (priceReport()) angebunden, dem konkret gemeldeten Fall. Andere Toasts (Favoriten, Login, GPS 526 etc.) bleiben unveraendert neutral, koennten bei Bedarf spaeter genauso eingefaerbt werden. */ 527#toast.toast--success { background: var(--green); } 528#toast.toast--error { background: #c0392b; } 529/* 11.08.2026 User-Wunsch: eigener Toast am oberen Kartenrand statt des globalen #toast (der 530 liegt ueber dem Header, nicht ueber der Karte) - nur fuer Karten-bezogene Hinweise wie 531 "Auto-Folgen an/aus", siehe showMapToast(). */ 532#map-toast { 533 display: none; position: absolute; top: calc(10px + var(--safe-t)); left: 50%; 534 transform: translateX(-50%); background: #333; color: #fff; 535 padding: 10px 20px; border-radius: 20px; font-size: .85rem; z-index: 600; 536 white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis; 537} 538 539/* ââ Route-View ââ */ 540#view-route { flex-direction: column; overflow: hidden; flex: 1; min-height: 0; } 541#route-form-panel { overflow-y: auto; flex: 1; min-height: 0; padding: 16px 16px 24px; } 542#route-result-panel { display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } 543#route-map-container { flex: 0 0 52%; min-height: 0; } 544#route-result-list { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 16px 16px; } 545 546.route-field { margin-bottom: 14px; } 547.route-field label { display: block; font-size: .78rem; color: var(--sub); font-weight: 700; 548 margin-bottom: 5px; text-transform: uppercase; letter-spacing: .03em; } 549.route-field input[type=text], .route-field input[type=number] { 550 width: 100%; box-sizing: border-box; border: 1.5px solid var(--border); 551 border-radius: 10px; padding: 11px 12px; font-size: .95rem; outline: none; 552 font-family: inherit; background: #fff; } 553.route-field input:focus { border-color: var(--blue); } 554.route-input-wrap { position: relative; } 555.route-input-wrap input { padding-right: 72px; } 556/* 18.07.26: "GPS"-Text statt reinem ð-Icon - auf Touchgeraeten gibt es keinen 557 Hover fuer den bisherigen title="Meinen Standort als Start"-Tooltip, das 558 Icon allein wurde nicht als "GPS-Standort nutzen" erkannt (User
558-Feedback). */ 559.btn-gps-inline { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); 560 background: none; border: 1.5px solid var(--blue); border-radius: 8px; cursor: pointer; 561 padding: 5px 8px; color: var(--blue); line-height: 1; font-size: .78rem; font-weight: 700; 562 white-space: nowrap; } 563 564.route-suggestions { background: #fff; border: 1px solid var(--border); border-radius: 10px; 565 margin-top: 3px; box-shadow: 0 6px 16px rgba(0,0,0,.12); display: none; 566 max-height: 170px; overflow-y: auto; z-index: 50; position: relative; } 567.route-suggestions.show { display: block; } 568.route-sugg-item { padding: 10px 12px; font-size: .88rem; cursor: pointer; 569 border-bottom: 1px solid var(--border); } 570.route-sugg-item:last-child { border-bottom: none; } 571.route-sugg-item:active { background: #f0f4f8; } 572.route-sugg-main { font-weight: 600; color: #222; } 573.route-sugg-sub { font-size: .75rem; color: var(--sub); margin-top: 1px; } 574 575.route-filter-toggle { background: none; border: 1px solid var(--border, #d1d5db); color: inherit; border-radius: 8px; 576 padding: 6px 12px; font-size: .85rem; cursor: pointer; margin-bottom: 10px; } 577#route-filter-box { margin-bottom: 10px; } 578.route-filter-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: .9rem; } 579.route-filter-row input { width: 18px; height: 18px; } 580.route-mode-row { display: flex; gap: 8px; margin-bottom: 14px; } 581.route-mode-btn { flex: 1; border: 1.5px solid var(--border); border-radius: 9px; 582 background: #fff; padding: 9px 4px; font-size: .82rem; font-weight: 700; 583 cursor: pointer; color: var(--sub); transition: all .15s; font-family: inherit; } 584.route-mode-btn.active { background: var(--blue); color: #fff; border-color: var(--blue); } 585 586.route-two-col { display: flex; gap: 10px; margin-bottom: 6px; } 587.route-two-col .route-field { flex: 1; margin-bottom: 0; } 588.route-two-col .route-field input { width: 100%; box-sizing: border-box; } 589input[type=number]::-webkit-inner-spin-button, 590input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; } 591input[type=number] { -moz-appearance: textfield; } 592 593.route-reserve-hint { font-size: .74rem; color: var(--sub); margin: -2px 0 14px; } 594.route-avoid-row { display:flex; align-items:center; justify-content:space-between; 595 font-size:.88rem; color:var(--sub); margin-bottom:14px; } 596.route-toggle { position:relative; display:inline-block; width:42px; height:24px; flex-shrink:0; } 597.route-toggle input { opacity:0; width:0; height:0; } 598.route-toggle-slider { position:absolute; cursor:pointer; inset:0; background:#d1d5db; 599 border-radius:24px; transition:.3s; } 600.route-toggle-slider:before { position:absolute; content:''; height:18px; width:18px; 601 left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.3s; } 602input:checked + .route-toggle-slider { background:var(--blue); } 603input:checked + .route-toggle-slider:before { transform:translateX(18px); } 604.wp-item { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #f3f4f6; } 605.wp-num { width:22px; height:22px; border-radius:50%; background:var(--blue); color:#fff; 606 font-size:.72rem; font-weight:700; display:flex; align-items:center; justify-content:center; flex-shrink:0; } 607.wp-label { flex:1; font-size:.87rem; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } 608.wp-del { background:none; border:none; color:#bbb; font-size:1rem; cursor:pointer; padding:4px; flex-shrink:0; } 609.wp-del:hover { color:#e33; } 610#btn-add-wp { display:block; background:none; border:none; color:var(--blue); 611 font-size:.87rem; cursor:pointer; padding:6px 0 10px; font-weight:600; width:100%; text-align:left; } 612.wp-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:9998; } 613.wp-sheet { position:fixed; bottom:0; left:0; right:0; background:#fff; border-radius:18px 18px 0 0; 614 padding:20px 16px calc(20px + env(safe-area-inset-bottom)); z-index:9999; 615 transform:translateY(100%); transition:transform .28s cubic-bezier(.4,0,.2,1); max-height:70vh; overflow-y:auto; } 616.wp-sheet.wp-open { transform:translateY(0); } 617.wp-sheet-title { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; } 618.wp-sheet-title span { font-weight:700; font-size:1rem; } 619.wp-sheet-title button { background:none; border:none; font-size:1.3rem; color:#666; cursor:pointer; padding:4px 6px; } 620#wp-add-btn { margin-top:14px; width:100%; background:var(--blue); color:#fff; border:none; 621 border-radius:10px; padding:12px; font-size:.95rem; font-weight:600; cursor:pointer; } 622#wp-add-btn:disabled { opacity:.4; cursor:default; } 623#wp-input { width:100%; box-sizing:border-box; border:1.5px solid var(--border); 624 border-radius:10px; padding:13px 14px; font-size:1rem; outline:none; 625 font-family:inherit; background:#fff; } 626#wp-input:focus { border-color:var(--blue); } 627#route-history-row { display:none; margin-bottom:12px; } 628#route-history-row.has-items { display:block; } 629.rh-label { font-size:.72rem; color:var(--sub); font-weight:700; text-transform:uppercase; 630 letter-spacing:.04em; margin-bottom:6px; } 631.rh-chips { display:flex; gap:8px; overflow-x:auto; padding-bottom:2px; -webkit-overflow-scrolling:touch; } 632.rh-chip { flex-shrink:0; display:flex; align-items:center; gap:6px; background:var(--card); 633 border:1.5px solid var(--border); border-radius:20px; padding:6px 10px 6px 12px; 634 font-size:.82rem; cursor:pointer; max-width:220px; white-space:nowrap; } 635.rh-chip:hover { border-color:var(--blue); } 636.rh-chip-text { overflow:hidden; text-overflow:ellipsis; flex:1; min-width:0; } 637.rh-chip-del { flex-shrink:0; background:none; border:none;
637 color:#bbb; font-size:.85rem; 638 cursor:pointer; padding:0 2px; line-height:1; } 639.rh-chip-del:hover { color:#e33; } 640#btn-route-calc { width: 100%; background: var(--blue); color: #fff; border: none; 641 border-radius: 12px; padding: 14px; font-size: 1rem; font-weight: 700; 642 cursor: pointer; font-family: inherit; } 643#btn-route-calc:disabled { opacity: .5; cursor: default; } 644@keyframes route-spin { to { transform: rotate(360deg); } } 645 646#route-error-msg { background: #fee2e2; border-radius: 10px; padding: 12px 14px; 647 margin-bottom: 10px; color: #991b1b; font-size: .85rem; } 648#route-info-bar { font-size: .78rem; color: var(--sub); margin-bottom: 12px; 649 display: flex; align-items: center; justify-content: space-between; gap: 8px; } 650#btn-route-print { flex-shrink: 0; background: var(--hover, #f0f4f8); border: 1px solid var(--border); 651 border-radius: 8px; width: 30px; height: 30px; font-size: 15px; line-height: 1; cursor: pointer; 652 display: flex; align-items: center; justify-content: center; padding: 0; } 653.route-stop-card { background: #fff; border-radius: 12px; padding: 12px 14px; 654 margin-bottom: 9px; box-shadow: 0 1px 6px rgba(0,0,0,.08); 655 display: flex; gap: 12px; align-items: flex-start; cursor: pointer; } 656.route-stop-num { flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; 657 background: #e87800; color: #fff; font-weight: 800; font-size: .85rem; 658 display: flex; align-items: center; justify-content: center; } 659.route-stop-body { flex: 1; min-width: 0; } 660.route-stop-name { font-weight: 700; font-size: .9rem; white-space: nowrap; 661 overflow: hidden; text-overflow: ellipsis; } 662.route-stop-addr { font-size: .76rem; color: var(--sub);
662 margin-top: 1px; } 663.route-stop-meta { display: flex; gap: 7px; margin-top: 5px; flex-wrap: wrap; align-items: center; } 664.route-no-stop { font-size: .9rem; color: #16a34a; font-weight: 700; 665 padding: 12px 0 4px; text-align: center; } 666.route-all-toggle { font-size: .82rem; color: var(--blue); cursor: pointer; 667 margin: 10px 0 4px; font-weight: 700; padding: 4px 0; } 668#route-all-list { display: none; } 669#route-all-list.show { display: block; } 670.route-station-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; 671 border-bottom: 1px solid var(--border); cursor: pointer; } 672.route-station-row:last-child { border-bottom: none; } 673.route-station-dot { flex-shrink: 0; width: 11px; height: 11px; border-radius: 50%; } 674.route-station-info { flex: 1; min-width: 0; } 675.route-station-name { font-size: .85rem; font-weight: 600; white-space: nowrap; 676 overflow: hidden; text-overflow: ellipsis; } 677.route-station-addr { font-size: .73rem; color: var(--sub); } 678#btn-route-reset { width: 100%; background: #f0f4f8; color: #555; border: none; 679 border-radius: 10px; padding: 12px; font-size: .88rem; font-weight: 600; 680 cursor: pointer; margin-top: 10px; font-family: inherit; } 681 682/* 10.08.26: Druckansicht auch in der PWA (User-Wunsch: "Auch dort (es gibt ja auch Tablets) eine 683 druckbare Liste anbieten", spaeter erweitert auf die Umkreissuche-Liste: "fuer die Liste der 684 Umkreissuche waere ein Druck auch sinnvoll"), analog zu routeplaner.php (Desktop). Beide Sheets 685 (.rpp-sheet) sind am Bildschirm immer unsichtbar; @media print blendet die gesamte App-Shell 686 (#app) aus und zeigt nur das jeweils gefuellte Sheet in festen hellen Farben (Dark-Mode soll 687 nicht mitgedruckt werden - Tinte/Lesbarkeit). */ 688.rpp-sheet { display: none; } 689@media print { 690 #app { display: none !important; } 691 .rpp-sheet { display: block; position: static; width: 100%; 692 color: #111; background: #fff; font-family: -apple-system, BlinkMacSystemFont, sans-serif; } 693 .rpp-sheet h1 { font-size: 18px; margin: 0 0 4px; } 694 .rpp-sheet .rpp-meta { font-size: 12px; color: #555; margin-bottom: 14px; } 695 .rpp-sheet table { width: 100%; border-collapse: collapse; font-size: 12px; } 696 .rpp-sheet th, .rpp-sheet td { text-align: left; padding: 5px 8px; 697 border-bottom: 1px solid #ccc; } 698 .rpp-sheet th { color: #555; font-weight: 600; } 699 .rpp-sheet .rpp-num { width: 26px; height: 26px; border-radius: 50%; background: #e87800; 700 color: #fff; font-weight: 700; font-size: 12px; text-align: center; line-height: 26px; 701 display: inline-block; } 702} 703 704/* ââ Profil / Login ââ */ 705#view-profile { flex: 1; min-height: 0; padding: 20px 16px; overflow-y: auto; display: flex; flex-direction: column; align-items: center; } 706#profile-login-form, #profile-loggedin { width: 100%; max-width: 420px; } 707 708/* ââ Hilfe ââ */ 709#view-help { flex: 1; min-height: 0; padding: 20px 16px; overflow-y: auto; display: flex; flex-direction: column; align-items: center; } 710#view-help code { background: var(--bg); border-radius: 4px; padding: 1px 6px; font-size: .82em; } 711.p-card { background: var(--card); border-radius: 14px; padding: 20px; margin-bottom: 14px; 712 box-shadow: 0 2px 8px rgba(0,0,0,.06); } 713/* 28.08.2026 (PLAN Tankbuch, User-Fund am echten Geraet: Tankbuch-Liste war hier komplett 714 ungestylt - .tb-*-Klassen waren bisher nur im <style> von tankbuch.php definiert, die PWA 715 bekam sie nie zu Gesicht. Kompaktes Karten-Layout statt Tabelle, siehe renderList() in 716 js/tankbuch.js, identisch zu tankbuch.php gehalten. */ 717.tb-entry { border-bottom: 1px solid var(--border); padding: 10px 0; } 718.tb-entry:last-child { border-bottom: none; } 719.tb-entry-row { display: flex; justify-content: space-between; gap: 10px; font-weight: 600; font-size: .88rem; } 720.tb-entry-detail { color: var(--sub); font-size: .78rem; margin-top: 2px; } 721.tb-entry-actions { display: flex; gap: 8px; margin-top: 6px; } 722.tb-entry-actions button { font-size: .74rem; padding: 3px 10px; border-radius: 4px; border: 1px solid var(--border); background: var(--card); color: var(--text); cursor: pointer; } 723.tb-empty { color: var(--sub); font-style: italic; } 724/* 28.08.2026 (User-Wunsch: "die PWA nur zur Eingabe/Kontrolle der letzten 3 Tankvorgaenge") - 725 Hinweis auf die volle Desktop-Verwaltung, erscheint nur in der PWA (moreLink-Option). Bewusst 726 kein Link (User-Wunsch: "nicht als Link, sondern nur als Hinweis!") - reiner Text. */ 727.tb-more-link { text-align: center; padding-top: 10px; font-size: .8rem; color: var(--sub); } 728/* 28.08.2026 (User-Wunsch: "wir brauchen dann noch einen design-loesung, wenn viele 729 Tankvorgaenge vorliegen") - feste Maximalhoehe mit eigenem Scrollbereich, damit die Liste bei 730 Monaten/Jahren an Eintraegen nicht die ganze View endlos in die Laenge zieht. Kleinerer Wert
731 als auf der Desktop-Seite, da in der PWA ohnehin schon Formular+Chart+Liste untereinander 732 in einer scrollbaren View stehen. */ 733#tb-list { max-height: 45vh; overflow-y: auto; } 734.p-card h2 { font-size: 1rem; font-weight: 700; margin-bottom: 14px; } 735.p-field { margin-bottom: 12px; } 736.p-field label { display: block; font-size: .72rem; color: var(--sub); font-weight: 700; 737 margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; } 738.p-field input, .p-field select { width: 100%; border: 1.5px solid var(--border); border-radius: 9px; 739 padding: 11px 13px; font-size: .95rem; line-height: 1.3; font-family: inherit; outline: none; background: var(--card); color: var(--text); } 740.p-field input:focus, .p-field select:focus { border-color: var(--blue); } 741/* 28.08.2026 (User-Fund: Kraftstoff-/Fahrzeug-Auswahl sah auf dem echten Geraet anders aus als 742 die uebrigen Formularfelder - hoehere Box, blaue System-Fokus-Umrandung, eigener Pfeil) - 743 natives Select-Aussehen zuruecksetzen und einen dezenten eigenen Pfeil zeichnen, damit Input 744 UND Select in der PWA identisch aussehen, auf jeder Plattform (iOS/Android/Desktop). */ 745.p-field select { -webkit-appearance: none; -moz-appearance: none; appearance: none; 746 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='11' height='7'><path d='M1 1l4.5 4.5L10 1' stroke='%236b7280' stroke-width='1.6' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>"); 747 background-repeat: no-repeat; background-position: right 14px center; padding-right: 34px; } 748/* 28.08.2026 (User-Frage: "kann man die Eingabe von Preis eleganter gestalten?") - fest 749 eingeblendetes Waehrungssymbol im Feld statt nur im Label, macht die Einheit beim Tippen 750 sichtbar. */ 751.tb-prefix-wrap { position: relative; } 752.tb-prefix { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--sub); font-size: .95rem; pointer-events: none; } 753.tb-prefix-wrap input { padding-left: 28px !important; } 754.btn-login { width: 100%; background: var(--blue); color: #fff; border: none; 755 border-radius: 10px; padding: 13px; font-size: 1rem; font-weight: 700; 756 cursor: pointer; font-family: inherit; } 757.btn-login:disabled { opacity: .5; } 758/* 16.08.26 User-Wunsch: kleine, runde Icon-Buttons fuer Apple/Google-Login DIREKT NEBEN dem 759 "Anmelden"-Button statt vollbreiter Buttons unter einem "oder"-Trenner (Vorbild: gaengiges 760 Icon-Login-Muster, User hat Screenshot gezeigt). .btn-login bleibt in dieser Zeile flexibel 761 statt 100% breit, IDs/Click-Handler unveraendert. */ 762.login-btn-row { display: flex; align-items: center; gap: 10px; } 763.login-btn-row .btn-login { width: auto; flex: 1; } 764.btn-oauth-icon { flex-shrink: 0; width: 46px; height: 46px; border-radius: 50%; 765 display: flex; align-items: center; justify-content: center; 766 cursor: pointer; border: none; padding: 0; } 767.btn-oauth-icon.btn-apple { background: #000; color: #fff; } 768.btn-oauth-icon.btn-google { background: #fff; color: #3c4043; border: 1.5px solid #dadce0 !important; } 769.btn-logout { width: 100%; background: #fee2e2; color: #dc2626; border: none; 770 border-radius: 9px; padding: 11px; font-size: .9rem; font-weight: 700; 771 cursor: pointer; font-family: inherit; margin-top: 12px; } 772.p-error { color: #dc2626; font-size: .82rem; margin: 6px 0 8px; display: none; } 773.p-avatar { width: 50px; height: 50px; border-radius: 50%; background: var(--blue); 774 color: #fff; display: flex; align-items: center; justify-content: center; 775 font-size: 1.4rem; font-weight: 700; flex-shrink: 0; } 776.p-user-row { display: flex; align-items: center; gap: 14px; } 777.p-hint-row { font-size: .82rem; color: var(--sub); padding: 5px 0; } 778.gas-pick-row { display: flex; gap: 8px; margin-top: 8px; } 779.gas-pick-btn { flex: 1; padding: 9px 6px; border-radius: 9px; border: 2px solid var(--border); 780 background: var(--card); color: var(--text); font-size: .9rem; font-weight: 600; 781 cursor: pointer; font-family: inherit; transition: all .15s; } 782.gas-pick-btn.active { border-color: var(--blue); background: var(--blue); color: #fff; } 783.p-hint-row + .p-hint-row { margin-top: 4px; } 784.nav-btn.is-logged-in svg { stroke: var(--green); } 785
786/* 27.07.26 Dark-Mode Fortsetzung: einzelne Komponenten setzen Farben hartcodiert statt ueber 787 die Tokens (#fff-Flaechen, dunkler Text auf hellem Grund) - die faerben sich durch die 788 Token-Umdefinition oben NICHT automatisch um und muessten sonst als heller Kasten auf 789 dunklem Grund haengen bleiben. Gezielte Overrides fuer genau diese Faelle. */ 790:root[data-theme="dark"] #gps-radius, :root[data-theme="dark"] .fav-row, 791:root[data-theme="dark"] .route-suggestions, :root[data-theme="dark"] .route-mode-btn, 792:root[data-theme="dark"] .route-field input[type=text], :root[data-theme="dark"] .route-field input[type=number], 793:root[data-theme="dark"] .wp-sheet, :root[data-theme="dark"] #wp-input, :root[data-theme="dark"] .route-stop-card { background: var(--card); } 794:root[data-theme="dark"] #bottom-nav { background: var(--card); } 795:root[data-theme="dark"] #loader { background: rgba(0,0,0,.7); } 796:root[data-theme="dark"] .station-card:active { background: rgba(77,127,214,.15); } 797:root[data-theme="dark"] .fav-row-name, :root[data-theme="dark"] .route-sugg-main, :root[data-theme="dark"] .wp-label { color: var(--text); } 798:root[data-theme="dark"] .fav-tab[data-tab="local"].active { background: rgba(225,29,72,.15); } 799:root[data-theme="dark"] .fav-tab[data-tab="profile"].active { background: rgba(245,158,11,.15); } 800:root[data-theme="dark"] #route-error-msg { background: rgba(220,38,38,.18); } 801:root[data-theme="dark"] .station-card-bab { background: rgba(245,158,11,.12); } 802 803/* ââ Scanner-View (27.07.26, Integration des bisher isolierten mobile/scanner.php-Prototyps, 804 siehe Memory project_pwa_scanner) - eigene, bewusst IMMER dunkle Sonar-Optik, unabhaengig vom 805 Light/Dark-Mode der restlichen App (wie ein Kamera-/Radar-Bildschirm, der auch in hellen Apps 806 dunkel bleibt) - daher eigene, prefixte CSS-Variablen statt der globalen --bg/--card/--text. */ 807#view-scanner { 808 flex: 1; min-height: 0; display: none; flex-direction: column; 809 background: var(--sc-bg); color: var(--sc-text); 810 --sc-bg: #05070a; --sc-panel: #0d1520; --sc-line: #123322; 811 --sc-scan: #22e07a; --sc-scan2: #0fae5c; --sc-text: #d7fbe8; --sc-sub: #6f9484; --sc-amber: #ffb020; 812} 813/* 28.07.26 User-Wunsch: Querformat im Scanner verhindern - das Radar-Layout ist fest auf 814 Hochkant ausgelegt (Handy typischerweise im Auto-Halter). Echtes screen.orientation.lock() 815 funktioniert nur in installierten Android-PWAs, NICHT auf iOS/Safari (Apple unterstuetzt die 816 Screen-Orientation-Lock-API grundsaetzlich nicht) - der Lock-Versuch dafuer steht im Script 817 weiter unten (startScannerTracking()), rein als Bonus fuer Android. Zuverlaessig auf allen 818 Geraeten ist nur diese CSS-Loesung: im Querformat wird der Radar-Inhalt ausgeblendet und ein 819 Dreh-Hinweis gezeigt. */ 820#sc-rotate-notice { display: none; } 821@media (orientation: landscape) { 822 #view-scanner #sc-status, #view-scanner #sc-btn-compass, #view-scanner #sc-ctrl, 823 #view-scanner #sc-radar-wrap, #view-scanner #sc-below-radar { display: none !important; } 824 #view-scanner #sc-rotate-notice { 825 display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; 826 gap: 14px; padding: 24px; text-align: center; color: var(--sc-sub); 827 } 828 #sc-rotate-notice svg { width: 48px; height: 48px; color: var(--sc-scan); } 829} 830#sc-status { padding: 8px 16px; font-size: .82rem; color: var(--sc-sub); text-align: center; min-height: 20px; } 831#sc-status.ok { color: var(--sc-scan); } 832#sc-status.warn { color: var(--sc-amber); } 833#sc-btn-compass { 834 display: none; margin: 0 16px 8px; background: rgba(255,176,32,.15); border: 1px solid var(--sc-amber); 835 color: var(--sc-amber); border-radius: 20px; padding: 6px 12px; font-size: .78rem; font-weight: 600; cursor: pointer; 836} 837#sc-ctrl { display: flex; justify-content: center; gap: 8px; padding: 0 16px 10px; flex-wrap: wrap; } 838.sc-ctrl-btn { 839 background: var(--sc-panel); border: 1px solid var(--sc-line); color: var(--sc-sub); 840 border-radius: 20px; padding: 6px 13px; font-size: .78rem; font-weight: 600; cursor: pointer; 841} 842.sc-ctrl-btn.active { background: var(--sc-scan2); border-color: var(--sc-scan); color: #04140b; } 843/* 14.08.2026 User-Wunsch ("möglichst viel vom freien Bereich nutzen"): 40vh/300px liess auf den 844 meisten Geräten viel ungenutzten Platz unter der Stationsliste - deutlich vergrössert. 845 #sc-below-radar bleibt flex:1 mit eigenem Scroll, verschwindet also nie komplett, schrumpft 846 nur passend zum verfügbaren Rest. */ 847#sc-radar-wrap { flex: 0 0 auto; height: 58vh; max-height: 480px; display: flex; align-items: center; justify-content: center; padding: 6px 10px 4px; min-height: 0; } 848#sc-radar { display: block; touch-action: none; } 849#sc-below-radar { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; } 850#sc-blip-card { 851 display: none; margin: 0 16px 8px; background: var(--sc-panel); border: 1px solid var(--sc-scan2); 852 border-radius: 12px; padding: 10px 14px; font-size: .85rem; 853} 854#sc-blip-card .name { font-weight: 700; color: var(--sc-text); margin-bottom: 2px; } 855#sc-blip-card .meta { color: var(--sc-sub); font-size: .78rem; } 856#sc-blip-card .oez { color: var(--sc-sub); font-size: .78rem; margin-top: 4px; } 857#sc-blip-card .price { float: right; font-weight: 700; font-size: 1rem; color: var(--sc-scan); } 858#sc-blip-card .nav-link { 859 display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 10px; 860 background: var(--sc-scan2); color: #04140b; text-decoration: none; font-weight: 700; 861 font-size: .82rem; border-radius: 9px; padding: 9px; 862} 863#sc-sector-list { padding: 0 16px calc(var(--safe-b) + 14px); } 864#sc-sector-list .hdr { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--sc-sub); margin: 4px 0 6px; } 865.sc-row { 866 display: flex; align-items: center; gap: 10px; padding: 8px 10px; margin-bottom: 6px; 867 background: var(--sc-panel); border: 1px solid var(--sc-line); border-radius: 10px; font-size: .82rem; 868} 869.sc-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } 870.sc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--sc-text); } 871.sc-dist { color: var(--sc-sub); font-size: .76rem; flex-shrink: 0; } 872.sc-price { font-weight: 700; flex-shrink: 0; min-width: 58px; text-align: right; color: var(--sc-text); } 873#sc-sector-empty { color: var(--sc-sub); font-size: .82rem; text-align: center; padding: 18px 10px; } 874</style>
875<script> 876// 27.07.26: Theme so frueh wie moeglich (noch vor dem restlichen App-Script weiter unten) 877// setzen, damit bei "Dunkel" bzw. "Auto"+dunklem System kein kurzes Aufblitzen der hellen 878// Seite entsteht. Der Rest der Theme-Logik (Umschalter im Profil) folgt im Haupt-Script. 879(function () { 880 const t = localStorage.getItem('gasdb_theme') || 'auto'; 881 const eff = t === 'auto' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : t; 882 document.documentElement.setAttribute('data-theme', eff); 883})(); 884</script>
884 885</head> 886<body> 887<div id="ptr-indicator" class="ptr-indicator"> 888 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg> 889</div> 890<!-- 11.09.2026: Update-Hinweis-Banner, siehe Kommentar zu $this_version_boot oben - erscheint nur, 891 wenn checkForUpdate() eine abweichende Server-Version findet, Klick laedt die Seite neu. --> 892<div id="update-banner" onclick="location.reload()" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;background:var(--green);color:#fff;text-align:center;font-size:.85rem;font-weight:600;padding:10px 12px;cursor:pointer"> 893 ð Neue Version verfügbar â zum Aktualisieren tippen 894</div> 895<div id="app"> 896 897 <!-- Header --> 898 <div id="header"> 899 <!-- 28.08.2026 (User-Fund: "ein Klick auf Suche (Lupe) und Home (Haus) loesen das gleiche 900 aus, damit koennte man die Lupe im Menue einsparen" - Logo-Icon und der "Suche"-Button 901 im Bottom-Nav riefen beide identisch showView('home') auf. User-Idee: statt den 902 Bottom-Nav-Button ersatzlos zu streichen, das Logo-Icon selbst durch eine Lupe ersetzen 903 - passt semantisch ohnehin besser (fuehrt zur Suche, nicht zu einem Dashboard), macht 904 die Redundanz sichtbar auf und spart Platz in der Fussleiste (dort waren zuletzt 7 905 Buttons, siehe Bottom-Nav-Overflow-Fix). Logo-Icons in der Kopfzeile brauchen anders 906 als Tab-Buttons keine eigene Aktiv-Markierung. --> 907 <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" onclick="showView('home')" style="cursor:pointer"> 908 <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/> 909 </svg> 910 <h1>Gas­Tankstellen</h1> 911 <!-- 11.08.2026 User-Wunsch: dauerhafter, aber dezenter Hinweis bei fehlender Verbindung 912 (nicht nur ein einmaliger Toast) - User-Feedback zur ersten Version (volle Breite ganz 913 oben): "zu weit oben, da haben Handys idR andere Sachen anzuzeigen" - jetzt als kleiner, 914 gedrehter Stempel zwischen Titel und Versions-Badge. Siehe 915 window.addEventListener('online'/'offline', ...) unten. --> 916 <span id="offline-badge" style="display:none;transform:rotate(-25deg);color:#c0392b;border:1.5px solid #c0392b;border-radius:4px;padding:0 4px;font-size:.62rem;font-weight:800;letter-spacing:.03em;margin-right:4px">OFFLINE</span> 917 <!-- 11.09.2026 (User-Wunsch: "rechts oben in der PWA sieht man 'LPG * CNG', lass uns genau 918 DAS als Umschalter für die Gasart nehmen und damit Platz auf dem Display sparen. Die 919 aktive Gasart soll grösser und grün angezeigt werden. Reihenfolge bleibt wie sie ist") 920 - ersetzt die vormals rein dekorative "LPG · CNG"-Anzeige UND die separate Mode-Tab-Zeile 921 (volle Breite unter der Suchleiste) durch EIN kompaktes Element in der Kopfzeile. id/ 922 class/data-mode bleiben absichtlich identisch zur vorherigen #mode-bar/.mode-btn-Struktur 923 - dadurch funktioniert die gesamte bestehende setMode()/Sichtbarkeits-Logik (Route- 924 Ansicht ausblenden, Gasart-Sperre bei Usern mit festem Profil-Gas) unveraendert weiter, 925 ohne dass an den mehreren Stellen, die #mode-bar ein-/ausblenden, etwas geaendert werden 926 musste. --> 927 <div id="mode-bar"> 928 <button class="mode-btn active" data-mode="lpg">LPG</button> 929 <span id="mode-switch" aria-hidden="true"><span id="mode-switch-thumb"></span></span> 930 <button class="mode-btn" data-mode="cng">CNG</button> 931 </div> 932 </div> 933 934 <!-- Suchleiste --> 935 <div id="search-bar"> 936 <input id="search-input" type="search" placeholder="PLZ, Ort, Kennzeichen oder Autobahn (A2) â¦" autocomplete="off" enterkeyhint="search"> 937 <button id="btn-search-filter" type="button" aria-label="Filter" title="Filter">ð½ <span id="sf-count"></span></button> 938 <button id="btn-search" type="button" aria-label="Suchen"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <span class="btn-search-label">Suchen</span></button> 939 </div> 940 <div id="search-filter-panel"> 941 <label class="sf-row"><input type="checkbox" class="sf-cb" value="ec"> ð³ EC/GiroCard</label> 942 <label class="sf-row"><input type="checkbox" class="sf-cb" value="kk"> ð³ Kreditkarte</label> 943 <label class="sf-row"><input type="checkbox" class="sf-cb" value="d7"> ð täglich geöffnet</label> 944 <label class="sf-row"><input type="checkbox" class="sf-cb" value="h24"> ð 24h geöffnet</label> 945 <label class="sf-row"><input type="checkbox" class="sf-cb" value="bd"> â½ auch Benzin</label> 946 <label class="sf-row"><input type="checkbox" class="sf-cb" value="lkw"> ð LKW-geeignet</label> 947 </div> 948 949 <!-- View: Home --> 950 <!-- 29.08.2026 (Code-Qualitaet, User-Wunsch: "PWA in Includes aufteilen wie beim Tankbuch") - 951 reine Markup-Extraktion, siehe mobile/home.inc.php. --> 952 <!-- mobile/home.inc.php â Home/Suche-View der PWA 953 29.08.2026 (Code-Qualitaet, User-Wunsch: "PWA zur Verbesserung der Code-Qualitaet in 954 Includes aufteilen, so wie beim Tankbuch") - reine Markup-Extraktion aus mobile/index.php, 955 keine Verhaltensaenderung. Nutzt Variablen aus dem einschliessenden Scope 956 ($_pwa_db_import_time), da PHP include() im Scope der aufrufenden Datei ausgefuehrt wird. --> 957<div id="view-home"> 958 <div id="home-center"> 959 <div style="font-size:3.5rem">â½</div> 960 <div style="font-size:.7rem;color:var(--sub);margin-bottom:-8px">26.10.01-4</div> 961 <button id="btn-open-help" type="button" style="background:none;border:none;color:var(--blue);font-size:.75rem;font-weight:600;margin-bottom:8px;
961cursor:pointer;padding:2px 8px">â Tipps zur Bedienung</button> 962 <h2 style="margin-top:12px;font-size:1.2rem">LPG & CNG in der Nähe</h2> 963 <p>Finde Flüssiggastankstellen<br>und Erdgastankstellen in deiner Nähe.</p> 964 <div style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap"> 965 <button id="btn-gps" style="position:relative"> 966 <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> 967 <circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/> 968 <path d="M12 8a4 4 0 100 8 4 4 0 000-8z" opacity=".3"/> 969 </svg> 970 Meinen Standort nutzen 971 </button> 972 <select id="gps-radius" title="Suchradius"> 973 <option value="2.5">2,5 km</option> 974 <option value="5">5 km</option> 975 <option value="7.5">7,5 km</option> 976 <option value="10">10 km</option> 977 <option value="20">20 km</option> 978 <option value="30">30 km</option> 979 </select> 980 </div> 981 </div> 982 <div id="fav-tabs" style="display:none"> 983 <button class="fav-tab active" data-tab="local" title="Lokale Favoriten">â¥</button> 984 <button class="fav-tab" data-tab="profile" title="Profil-Favoriten">â </button> 985 </div> 986 <div id="fav-section"></div> 987</div> 988 989 <!-- View: Liste --> 990 <!-- mobile/list.inc.php â Listen-View der PWA 991 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 992 Verhaltensaenderung. --> 993<div id="view-list"> 994 <div id="list-header"> 995 <span id="list-title">Ergebnisse</span> 996 <span style="display:flex;align-items:center;gap:8px"> 997 <span id="list-count"></span> 998 <button id="btn-list-print" aria-label="Liste drucken" title="Liste drucken">ð¨ï¸</button> 999 </span> 1000 </div> 1001 <div id="list-items"></div> 1002</div> 1003 1004 <!-- View: Detail --> 1005 <!-- mobile/detail.inc.php â Detail-View der PWA 1006 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 1007 Verhaltensaenderung. --> 1008<div id="view-detail"> 1009 <div id="detail-map"></div> 1010 <div id="detail-body"> 1011 <div id="detail-name"></div> 1012 <div id="detail-addr"></div> 1013 <div class="feat-badges" id="detail-badges" style="margin-top:6px"></div> 1014 <div class="detail-prices" id="detail-prices"></div> 1015 <div id="detail-infos"></div> 1016 <div class="detail-actions" id="detail-actions"></div> 1017 </div> 1018</div> 1019 1020 <!-- View: Karte --> 1021 <!-- mobile/map.inc.php â Karten-View der PWA 1022 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 1023 Verhaltensaenderung. --> 1024<div id="view-map">
1025 <div id="full-map"></div> 1026 <div id="map-toast"></div> 1027 <button id="map-large-toggle" type="button" title="GroÃe Ansicht (weniger Tankstellen, besser lesbar)">ð</button> 1028 <button id="map-auto-toggle" type="button" title="Auto-Modus (Standort + Karte alle 5 Minuten automatisch aktualisieren)">ð</button> 1029 <div id="map-radius-ctrl"> 1030 <button id="map-radius-minus" type="button" title="Kleinerer Umkreis">â</button> 1031 <span id="map-radius-label"></span> 1032 <button id="map-radius-plus" type="button" title="GröÃerer Umkreis">+</button> 1033 </div> 1034</div> 1035 1036 <!-- View: Scanner (27.07.26, Integration von mobile/scanner.php, siehe project_pwa_scanner) --> 1037 <!-- mobile/scanner.inc.php â Scanner-View der PWA (27.07.26, Integration von mobile/scanner.php, 1038 siehe project_pwa_scanner) 1039 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 1040 Verhaltensaenderung. --> 1041<div id="view-scanner"> 1042 <div id="sc-status">Warte auf Standortâ¦</div> 1043 <button id="sc-btn-compass">ð§ Kompass aktivieren</button> 1044 <div id="sc-ctrl"> 1045 <button class="sc-ctrl-btn" data-sc-range="2">2 km</button> 1046 <button class="sc-ctrl-btn active" data-sc-range="5">5 km</button> 1047 <button class="sc-ctrl-btn" data-sc-range="10">10 km</button> 1048 <button class="sc-ctrl-btn" data-sc-range="20">20 km</button> 1049 <button class="sc-ctrl-btn" data-sc-range="40">40 km</button> 1050 </div> 1051 <div id="sc-radar-wrap"><canvas id="sc-radar" width="340" height="240"></canvas></div> 1052 <div id="sc-below-radar"> 1053 <div id="sc-blip-card"></div> 1054 <div id="sc-sector-list"> 1055 <div class="hdr">Im Sektor voraus (±<span id="sc-sector-deg">60</span>°)</div> 1056 <div id="sc-sector-rows"></div> 1057 <div id="sc-sector-empty" style="display:none">Keine Station im Sektor voraus im gewählten Umkreis.</div> 1058 </div> 1059 </div> 1060 <div id="sc-rotate-notice"> 1061 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1062 <rect x="7" y="2" width="10" height="20" rx="2"/> 1063 <path d="M3 8 a9 9 0 0 1 6-6" stroke-dasharray="1.5 2.5"/> 1064 <path d="M3 8 l0 -3.5 M3 8 l3.2 -0.6" stroke-dasharray="none"/> 1065 </svg> 1066 <div>Bitte das Gerät hochkant halten â der Scanner ist auf Hochformat ausgelegt.</div> 1067 </div> 1068</div> 1069 1070 <!-- View: Hilfe --> 1071 <!-- mobile/help.inc.php â Hilfe-View der PWA ("Tipps zur Bedienung") 1072 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 1073 Verhaltensaenderung. --> 1074<div id="view-help" style="display:none"> 1075 <div style="width:100%;max-width:420px"> 1076 <button id="btn-close-help" type="button" style="background:none;border:none;color:var(--blue);font-size:.85rem;font-weight:600;margin-bottom:10px;cursor:pointer;padding:4px 0">â Zurück</button> 1077 <div class="p-card"> 1078 <h2 style="margin-bottom:14px">â Tipps zur Bedienung</h2> 1079 1080 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð Suche</div> 1081 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1082 Gib PLZ, Ort, ein KFZ-Kennzeichen-Kürzel (z. B. "BI") oder eine Autobahn (z. B. "A2") ein. 1083 Bei Adressen (mit Komma oder StraÃenwort wie "StraÃe"/"Weg") wird automatisch die genaue 1084 Adresse gesucht statt nur der Ort. 1085 </p> 1086 1087 <div class="p-hint-row" style="font-weight:600;color:var(--text)">â Direkt auf der Karte suchen</div> 1088 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1089 Mit einem <code>!</code> vor dem Suchbegriff (z. B. <code>!Soest</code>) wird direkt eine 1090 echte Karten-/Adresssuche ausgelöst, statt zuerst nur nach PLZ/Ort in unserer Tankstellen- 1091 Datenbank zu suchen. Hilfreich, wenn ein Ortsname dort nicht eindeutig gefunden wird. 1092 </p> 1093 1094 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð Suche auf ein Land eingrenzen</div> 1095 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1096 Manche Ortsnamen gibt es in mehreren Ländern (z. B. "Soest" in Deutschland <em>und</em> 1097 in den Niederlanden). Mit dem Präfix <code>XX-Ort</code> zwingst du die Suche auf ein 1098 bestimmtes Land, z. B. <code>!NL-Soest</code> oder <code>!AT-Graz</code> (am besten mit 1099 <code>!</code> kombiniert). Unterstützt: DE, AT, CH, NL, BE, FR, PL, CZ, LU. 1100 </p> 1101 1102 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð Standort verwenden</div> 1103 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1104 Der Standort-Button neben dem Suchfeld ermittelt deine aktuelle Position per GPS und 1105 zeigt Tankstellen in deiner Nähe â dein Browser fragt dabei einmalig um Erlaubnis. 1106 </p> 1107 1108 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð Schnellzugriff per Long Press</div> 1109 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1110 Länger auf "Liste" oder "Karte" unten im Menü drücken startet direkt eine Umkreissuche 1111 an deinem aktuellen GPS-Standort â ohne Umweg über das Suchfeld. Die Ergebnisliste bzw. 1112 -karte nutzt dabei den vollen Bildschirm. Der kleine <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:#e67e22;vertical-align:middle"></span> 1113 Punkt am jeweiligen Symbol zeigt, wo Long Press verfügbar ist. Länger direkt 1114 <em>innerhalb</em> der Karte auf eine Stelle drücken löst stattdessen eine neue Suche 1115 genau an diesem Ort aus. 1116 </p> 1117 1118 <div class="p-hint-row" style="font-weight:600;color:var(--text)">â½ LPG / CNG umschalten</div> 1119 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1120 <!-- 11.09.2026: Umschalter von einer eigenen Tab-Zeile in die Kopfzeile verlegt (User- 1121 Wunsch: "genau DAS als Umschalter nehmen und damit Platz sparen") - Hilfetext 1122 entsprechend angepasst, die groÃ+grün hervorgehobene Gasart oben rechts tippen. --> 1123 Oben rechts in der Kopfzeile antippen, um zwischen Autogas (LPG) und Erdgas (CNG) zu 1124 wechseln â die aktive Gasart ist grün und gröÃer dargestellt. Ergebnisliste und Karte 1125 passen sich sofort an. 1126 </p> 1127 1128 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ðºï¸ Karte</div> 1129 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1130 In der Kartenansicht siehst du alle Tankstellen im aktuellen Suchbereich auf einen Blick. 1131 Tippe auf einen Marker für Details, oder wechsle unten im Menü auf "Liste" zurück zur Listenansicht. 1132 Länger auf eine beliebige Stelle der Karte drücken (Long Press) startet eine neue Umkreissuche 1133 genau an dieser Position. 1134 </p> 1135 1136 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð¡ Scanner</div> 1137 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1138 Der Scanner zeigt Tankstellen in deiner Nähe live als Radar an, ausgerichtet nach 1139 Entfernung und Richtung â praktisch während der Fahrt, ohne die Liste durchsuchen zu 1140 müssen. Nutzt kontinuierliches GPS-Tracking, daher etwas höherer Akkuverbrauch als in 1141 den anderen Ansichten. 1142 </p> 1143 1144 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð§ Routenplaner</div> 1145 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1146 Plant eine Route mit Start, Ziel und optionalen Zwischenstopps â Tankstellen entlang der 1147 Strecke werden automatisch vorgeschlagen. 1148 </p> 1149 1150 <!-- 29.08.2026 (User-Frage: "ist der Hilfe-Text noch aktuell?") - Tankbuch fehlte 1151 komplett, obwohl es inzwischen ein vollwertiges Feature mit eigenem Menuepunkt ist. --> 1152 <div class="p-hint-row" style="font-weight:600;color:var(--text)">â½ Tankbuch</div> 1153 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1154 Angemeldet (Profil-Tab) erfasst du hier Tankdatum, Km-Stand, Liter und optional den 1155 Gesamtpreis â daraus berechnet die App deinen Verbrauch je Tankung und einen 1156 langfristigen Durchschnitt, auch für mehrere Fahrzeuge getrennt. Gelegentliches 1157 Benzin-Tanken (z. B. bei Erdgas-/Autogasfahrzeugen mit Reservetank) wird separat als 1158 Nebenstatistik geführt. Vollständige Verwaltung (mehrere Fahrzeuge anlegen, Einträge 1159 bearbeiten) gibt es auf der Website â die App zeigt hier bewusst nur die letzten 1160 Tankungen zur schnellen Eingabe unterwegs. 1161 </p> 1162 1163 <div class="p-hint-row" style="font-weight:600;color:var(--text)">⥠Lokale Favoriten</div> 1164 <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px"> 1165 Ohne Anmeldung als Favorit markierte Tankstellen bleiben nur auf diesem Gerät gespeichert 1166 (im Browser) â kein Login nötig, aber auch keine Synchronisierung zwischen Geräten. 1167 </p> 1168 1169 <div class="p-hint-row" style="font-weight:600;color:var(--text)">â Profil-Favoriten</div> 1170 <p style="font-size:.85rem;color:var(--sub);margin-bottom:0"> 1171 Angemeldet (Profil-Tab) kannst du Tankstellen als Favorit markieren â sie werden geräteübergreifend 1172 synchronisiert und deine Preismeldungen deinem Konto zugeordnet. Dasselbe â wie auf der Website. 1173 </p> 1174 </div> 1175 <div class="p-card" style="text-align:center;font-size:.82rem"> 1176 <a href="/impressum.php" target="_blank" rel="noopener" style="color:var(--blue);text-decoration:none">Impressum</a> 1177 · 1178 <a href="/datenschutzerklaerung.php" target="_blank" rel="noopener" style="color:var(--blue);text-decoration:none">Datenschutzerklärung</a> 1179 </div> 1180 </div> 1181</div> 1182 1183 <!-- View: Route --> 1184 <!-- mobile/route.inc.php â Routenplaner-View der PWA 1185 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 1186 Verhaltensaenderung. --> 1187<div id="view-route"> 1188 <!-- Formular --> 1189 <div id="route-form-panel"> 1190 <div id="route-history-row"> 1191 <div class="rh-label">ð Letzte Routen</div> 1192 <div class="rh-chips" id="rh-chips"></div> 1193 </div> 1194 <div class="route-field"> 1195 <label>ð¦ Start</label> 1196 <div class="route-input-wrap"> 1197 <input id="route-start-input" type="text" placeholder="Adresse eingebenâ¦" autocomplete="off">
1198 <button class="btn-gps-inline" id="btn-gps-route-inline" title="Meinen Standort als Start">ð GPS</button> 1199 </div> 1200 <div id="route-start-sugg" class="route-suggestions"></div> 1201 </div> 1202 <div id="route-wp-list" style="display:none"></div> 1203 <button id="btn-add-wp">+ Zwischenstopp</button> 1204 <div class="route-field"> 1205 <label>ð Ziel</label> 1206 <input id="route-end-input" type="text" placeholder="Zieladresse eingebenâ¦" autocomplete="off"> 1207 <div id="route-end-sugg" class="route-suggestions"></div> 1208 </div> 1209 <div class="route-mode-row"> 1210 <button class="route-mode-btn active" data-rmode="lpg">â½ LPG</button> 1211 <button class="route-mode-btn" data-rmode="cng">ðµ CNG</button> 1212 1213 </div> 1214 <!-- 18.09.2026 (User: "in der PWA sehe ich den Filter noch nicht") - gleiche Merkmals-Filter wie im 1215 Desktop-Routenplaner, serverseitig ueber route.php "filters". --> 1216 <button id="btn-route-filter-toggle" type="button" class="route-filter-toggle">ð½ Filter <span id="route-filter-count"></span></button> 1217 <div id="route-filter-box" style="display:none"> 1218 <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="ec"> ð³ nur mit EC/GiroCard</label> 1219 <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="kk"> ð³ nur mit Kreditkarte (Mastercard/Visa)</label> 1220 <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="d7"> ð nur täglich geöffnet</label> 1221 <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="h24"> ð nur 24h geöffnet</label> 1222 <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="bd"> â½ nur auch mit Benzin</label> 1223 <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="lkw"> ð nur auch LKW-geeignet</label> 1224 </div> 1225 <div class="route-two-col"> 1226 <div class="route-field"> 1227 <label>ð Reichweite (km)</label> 1228 <input id="route-range" type="number" value="350" min="50" max="800" step="10"> 1229 </div> 1230 <div class="route-field"> 1231 <label>âï¸ Korridor (km)</label> 1232 <input id="route-corridor" type="number" value="15" min="5" max="30" step="5"> 1233 </div> 1234 </div> 1235 <div class="route-reserve-hint">â ï¸ 50 km Reserve wird automatisch abgezogen</div> 1236 <div class="route-avoid-row"> 1237 <span>Autobahn vermeiden</span> 1238 <label class="route-toggle"> 1239 <input type="checkbox" id="route-avoid-motorway"> 1240 <span class="route-toggle-slider"></span> 1241 </label> 1242 </div> 1243 <button id="btn-route-calc">Route berechnen ð</button> 1244 </div> 1245 <!-- Ergebnis --> 1246 <div id="route-result-panel"> 1247 <div id="route-map-container"></div> 1248 <div id="route-result-list"> 1249 <div id="route-error-msg" style="display:none"></div> 1250 <div id="route-info-bar"></div> 1251 <div id="route-stops"></div> 1252 <div class="route-all-toggle" id="route-all-toggle">â¶ Alle Stationen im Korridor</div> 1253 <div id="route-all-list"></div> 1254 <button id="btn-route-reset">â Neue Route</button> 1255 </div> 1256 </div> 1257</div> 1258 1259 <!-- View: Profil --> 1260 <!-- mobile/profile.inc.php â Profil-View der PWA (Login/Logout, Darstellung, Gas-Typ, Spenden, 1261 Admin-Kennzahlen) 1262 29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine 1263 Verhaltensaenderung. --> 1264<div id="view-profile"> 1265 <!-- 27.07.26 User-Wunsch: manueller Light/Dark/Auto-Umschalter im Profil, uebersteuert die 1266 Systemeinstellung (siehe applyTheme() weiter unten). Immer sichtbar, unabhaengig vom 1267 Login-Status. --> 1268 <div class="p-card" style="width:100%;max-width:420px"> 1269 <div class="p-hint-row" style="font-weight:600;color:var(--text)">ð¨ Darstellung</div> 1270 <div class="gas-pick-row"> 1271 <button class="gas-pick-btn" id="theme-light" data-theme-choice="light">âï¸ Hell</button> 1272 <button class="gas-pick-btn" id="theme-dark" data-theme-choice="dark">ð Dunkel</button> 1273 <button class="gas-pick-btn" id="theme-auto" data-theme-choice="auto">ð± Auto</button> 1274 </div> 1275 </div> 1276 <div id="profile-login-form"> 1277 <div class="p-card"> 1278 <h2>Anmelden</h2> 1279 <div class="p-field"> 1280 <label>Benutzername</label> 1281 <input type="text" id="profile-username" autocomplete="username" placeholder="Benutzername"> 1282 </div> 1283 <div class="p-field"> 1284 <label>Passwort</label> 1285 <input type="password" id="profile-password" autocomplete="current-password" placeholder="Passwort"> 1286 </div> 1287 <div class="p-field" id="profile-otp-field" style="display:none;"> 1288 <label>2FA-Code</label> 1289 <input type="text" id="profile-otp-code" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="123456"> 1290 </div> 1291 <div class="p-error" id="profile-error"></div> 1292 <div class="login-btn-row"> 1293 <button class="btn-login" id="btn-do-login">Anmelden</button>
1294 <button class="btn-oauth-icon btn-apple" id="btn-apple-signin" title="Mit Apple anmelden" aria-label="Mit Apple anmelden"> 1295 <svg width="20" height="20" viewBox="0 0 814 1000" fill="currentColor"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105-43.2-150.3-109.5C82.7 763.3 31.4 674.7 19.7 603.5c-2.6-15.7-3.9-31.3-3.9-46.7 0-168.3 110.8-257.2 219.7-257.2 59.2 0 108.5 39.2 145.4 39.2 35.4 0 90.5-41.5 158.1-41.5 28.4 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg> 1296 </button> 1297 <button class="btn-oauth-icon btn-google" id="btn-google-signin" title="Mit Google anmelden" aria-label="Mit Google anmelden"> 1298 <svg width="20" height="20" viewBox="0 0 48 48"><path fill="#4285F4" d="M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v8.5h12.7c-.6 3-2.3 5.5-4.8 7.2v6h7.7c4.5-4.2 7.1-10.3 7.1-17.2z"/><path fill="#34A853" d="M24 47c6.5 0 11.9-2.1 15.9-5.8l-7.7-6c-2.1 1.4-4.9 2.3-8.2 2.3-6.3 0-11.6-4.2-13.5-9.9H2.5v6.2C6.5 41.8 14.7 47 24 47z"/><path fill="#FBBC05" d="M10.5 28.6c-.5-1.4-.8-2.9-.8-4.6s.3-3.2.8-4.6v-6.2H2.5C.9 16.6 0 20.2 0 24s.9 7.4 2.5 10.8l8-6.2z"/><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9.1 3.6l6.8-6.8C35.9 2.4 30.5 0 24 0 14.7 0 6.5 5.2 2.5 13.2l8 6.2C12.4 13.7 17.7 9.5 24 9.5z"/></svg> 1299 </button> 1300 </div> 1301 </div> 1302 <!-- Link-Verknüpfungs-Dialog (erscheint wenn E-Mail bereits existiert) --> 1303 <div id="oauth-link-box" style="display:none" class="p-card"> 1304 <p id="oauth-link-msg" style="font-size:.9rem;margin-bottom:10px;"></p> 1305 <div class="p-field"> 1306 <label>Passwort bestätigen</label> 1307 <input type="password" id="oauth-link-pw" placeholder="Passwort"> 1308 </div> 1309 <div class="p-error" id="oauth-link-error"></div> 1310 <button class="btn-login" id="btn-oauth-link">Verknüpfen & anmelden</button> 1311 <button class="btn-login" id="btn-oauth-link-cancel" style="background:var(--bg);color:var(--text);border:2px solid var(--border);margin-top:8px">Abbrechen</button> 1312 </div> 1313 </div> 1314 <div id="profile-loggedin" style="display:none"> 1315 <div class="p-card"> 1316 <div class="p-user-row"> 1317 <div class="p-avatar" id="profile-avatar">?</div> 1318 <div> 1319 <!-- 28.08.2026 (User-Wunsch: "primär den Usernamen (Login-Namen) und darunter klein 1320 den Real-name") - vorher stand vorname (Realname) oben, Username war gar nicht 1321 sichtbar; jetzt umgekehrt: Login-Name gross, Realname klein darunter. --> 1322 <div id="profile-name" style="font-weight:700;font-size:1rem"></div> 1323 <div id="profile-realname" style="font-size:.78rem;color:var(--sub)"></div> 1324 </div> 1325 </div>
1326 <button class="btn-logout" id="btn-do-logout">Abmelden</button> 1327 </div> 1328 <div class="p-card"> 1329 <div class="p-hint-row" style="font-weight:600;color:var(--text)">â½ Gas-Typ einstellen</div> 1330 <div class="gas-pick-row"> 1331 <button class="gas-pick-btn" id="pick-lpg" data-gas="lpg">â½ LPG</button> 1332 <button class="gas-pick-btn" id="pick-cng" data-gas="cng">ðµ CNG</button> 1333 </div> 1334 <div class="p-hint-row">⥠Favoriten merken â auf allen Geräten synchron</div> 1335 <div class="p-hint-row">â Preismeldungen werden deinem Konto zugeordnet</div> 1336 <!-- 05.09.2026 (User-Wunsch: "im Profil nach 'Preismeldungen werden'... ein 1337 '-> Schnellupdate-Link' hinzufügen mit dem /mobile/q") - Zugang zur Home-Bildschirm- 1338 Einrichtungsseite fuer den "an der Tankstelle vorbeifahren"-Anwendungsfall. Mehrere 1339 Anlaeufe, den Sprung aus der bereits als Standalone laufenden Haupt-App heraus direkt 1340 in echtes Safari zu erzwingen (x-safari-https://-Praefix, target="_blank", 1341 Redirect-auf-sich-selbst, echter Cross-Origin-Redirect ueber die bare-domain) zeigten 1342 beim Live-Test am echten iPhone alle KEINEN zuverlaessigen Erfolg - iOS' Verhalten fuer 1343 target="_blank" aus einer Standalone-PWA heraus ist offenbar grundsaetzlich flakey 1344 (funktionierte einmal zufaellig, beim naechsten identischen Test nicht mehr, unabhaengig 1345 von allen versuchten Tricks). User-Idee als 100% zuverlaessige Alternative: "kannst Du 1346 per Klick den Link in die Zwischenablage kopieren und einen Hinweis geben, den in 1347 Safari einzufuegen?" - kein Verlassen-des-Standalone-Containers mehr noetig, der User 1348 oeffnet Safari selbst manuell und fuegt die kopierte URL ein. Aus einem Link ein 1349 `<button>` gemacht (kein href mehr, daher kein target="_blank"-Verhalten mehr relevant); 1350 der Klick-Handler (per navigator.clipboard.writeText(), siehe mobile/index.php) kopiert 1351 die vollstaendige URL und zeigt per showToast() die Anleitung. --> 1352 <div class="p-hint-row"><button type="button" id="btn-qgo-copy" data-qgo-url="https://gas-tankstellen.de/mobile/q" style="background:none;border:none;padding:0;font:inherit;color:var(--blue);font-weight:600;cursor:pointer">â Schnellupdate-Link kopieren (zum Home-Bildschirm hinzufügen)</button></div> 1353 </div> 1354 </div> 1355 <div id="profile-donate-card" style="width:100%;max-width:420px;margin-top:8px"> 1356 <div class="p-card" style="text-align:center"> 1357 <div style="font-weight:700;font-size:.95rem;margin-bottom:10px">âï¸ Projekt unterstützen</div> 1358 <p style="font-size:.82rem;color:var(--sub);margin-bottom:14px">Diese App ist kostenlos und werbefrei. 1359 Ãber eine kleine Spende freue ich mich sehr!</p> 1360 <a href="https://www.buymeacoffee.com/7opghqhhsy" target="_blank" rel="noopener" 1361 style="display:inline-block;background:#FFDD00;color:#000;font-weight:700; 1362 border-radius:10px;padding:11px 22px;font-size:.95rem;text-decoration:none; 1363 border:2px solid #000">â Buy me a coffee</a> 1364 1365 <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_xclick&business=info%40neumann%2dedv%2ede&undefined_quantity=1&item_name=Unterst%C3%BCtzung%20der%20www%2egas%2dtankstellen%2einfo&item_number=60002&amount=5%2e00&no_shipping=1&return=http%3a%2f%2fwww%2egas%2dtankstellen%2einfo%2finfo_pp_danke%2ephp&no_note=1¤cy_code=EUR&lc=DE&bn=PP%2dBuyNowBF&charset=UTF%2d8" 1366 target="_blank" rel="noopener" title="Per PayPal spenden" 1367 style="display:inline-block;vertical-align:middle"><img src="/mobile/paypal5.gif" alt="PayPal spenden" style="height:31px;border:0"></a> 1368 </div> 1369 </div> 1370 <div id="profile-admin-card" style="width:100%;max-width:420px;margin-top:8px;display:none"> 1371 <div class="p-card"> 1372 <div style="font-weight:700;font-size:.95rem;margin-bottom:10px">ð Admin-Kennzahlen</div> 1373 <div id="admin-stats-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:.82rem;"> 1374 <div style="text-align:center"><div id="stat-pageviews" style="font-size:1.3rem;font-weight:700">â</div>Seitenaufrufe (24h)</div> 1375 <div style="text-align:center"><div id="stat-apicalls" style="font-size:1.3rem;font-weight:700">â</div>API-Calls (24h)</div> 1376 <div style="text-align:center"><div id="stat-activeusers" style="font-size:1.3rem;font-weight:700">â</div>Aktive User (30T)</div> 1377 <div style="text-align:center"><div id="stat-loginpct" style="font-size:1.3rem;font-weight:700">â</div>Angemeldet (24h)</div> 1378 </div> 1379 </div> 1380 </div> 1381</div> 1382 1383 <!-- mobile/tankbuch.inc.php â Tankbuch-View der PWA 1384 28.08.2026 (PLAN Tankbuch): ausgelagert statt direkt in mobile/index.php (das bereits weit 1385 ueber dem 500-Zeilen-Projektlimit liegt), per include() an der Stelle der anderen View-Divs 1386 eingebunden. Nutzt dieselben Element-IDs wie tankbuch.php (
1386Desktop), damit js/tankbuch.js 1387 ohne Anpassung in beiden Kontexten funktioniert. Login-Gate hier ist nur kosmetisch 1388 (siehe #tankbuch-login-hint) - der eigentliche Schutz liegt in mobile/api/tankbuch.php. --> 1389<div id="view-tankbuch"> 1390 1391 <div id="tankbuch-login-hint" class="p-card" style="display:none"> 1392 <div class="p-hint-row">ð Bitte im Profil anmelden, um das Tankbuch zu nutzen.</div> 1393 </div> 1394 1395 <div id="tankbuch-content" style="display:none;width:100%;max-width:420px"> 1396 <!-- 28.08.2026 (Zweitfahrzeug-Erweiterung, User-Wunsch: "Auswahl bei der Eingabe, Bezeichner 1397 nur auf der Website anpassbar") - reine Auswahl-Dropdown, keine Verwaltungs-Buttons hier, 1398 um das PWA-Interface minimal zu halten. Wird von js/tankbuch.js nur eingeblendet, wenn 1399 mehr als ein Fahrzeug existiert. --> 1400 <div class="p-card" id="tb-fahrzeug-wrap" style="display:none"> 1401 <div class="p-field" style="margin-bottom:0"> 1402 <label>Fahrzeug</label> 1403 <select id="tb-fahrzeug"></select> 1404 </div> 1405 </div> 1406 1407 <div class="p-card"> 1408 <form id="tb-form"> 1409 <div class="p-field"> 1410 <label>Tankdatum</label> 1411 <input type="date" id="tb-datum" required> 1412 </div> 1413 <div class="p-field"> 1414 <label>Km-Stand</label> 1415 <!-- 28.08.2026 (User-Frage: "kann man die Eingabe von Preis und km eleganter 1416 gestalten?") - type=number zeigt auf iOS ein Tastenfeld mit +/-/e und 1417 unpassenden Spinner-Pfeilen; inputmode=numeric gibt das reine Ziffernblock, 1418 Sanitizing per JS (siehe initTankbuch() in js/tankbuch.js) haelt nur Ziffern zu. --> 1419 <input type="text" inputmode="numeric" pattern="[0-9]*" id="tb-km" required> 1420 </div> 1421 <div class="p-field"> 1422 <label>Liter</label> 1423 <!-- 28.08.2026 (User-Wunsch: "Literangabe doch bitte mit 2 Dezimalstellen") - 1424 zurueckgerollt von ganzzahlig auf Dezimal-Tastenfeld wie Preis. --> 1425 <input type="text" inputmode="decimal" id="tb-liter" required> 1426 </div> 1427 <div class="p-field"> 1428 <label>Preis gesamt (optional)</label> 1429 <div class="tb-prefix-wrap"> 1430 <span class="tb-prefix">â¬</span> 1431 <input type="text" inputmode="decimal" id="tb-preis" placeholder="0,00"> 1432 </div> 1433 </div> 1434 <!-- 28.08.2026 (Benzin-Nebenstatistik, User-Wunsch: "Default ist die gewaehlte 1435 Gas-Art") - Gas vorausgewaehlt, Benzin ist die seltene Ausnahme. --> 1436 <div class="p-field"> 1437 <label>Kraftstoff</label> 1438 <select id="tb-kraftstoff"> 1439 <option value="gas" selected>Gas</option> 1440 <option value="benzin">Benzin</option> 1441 </select> 1442 </div> 1443 <!-- 28.08.2026 ("voll getankt"-Checkbox) - nur bei Kraftstoff=Benzin sichtbar, 1444 siehe updateVollgetanktVisibility() in js/tankbuch.js. --> 1445 <div class="p-field" id="tb-vollgetankt-wrap" style="display:none"> 1446 <label style="display:flex;align-items:center;gap:6px;font-weight:400;text-transform:none;letter-spacing:normal;cursor:pointer"> 1447 <input type="checkbox" id="tb-vollgetankt" style="width:auto"> voll getankt 1448 </label> 1449 </div> 1450 <div class="p-field" style="display:flex;gap:8px"> 1451 <button type="submit" id="tb-form-submit" class="gas-pick-btn">Hinzufügen</button> 1452 <button type="button" id="tb-form-cancel" class="gas-pick-btn" style="display:none">Abbrechen</button> 1453 </div> 1454 </form> 1455 </div> 1456 1457 <div class="p-card"> 1458 <div id="tb-stats" class="p-hint-row"></div> 1459 <div id="tb-stats-benzin" class="p-hint-row" style="opacity:.7;font-size:.8rem"></div> 1460 <canvas id="tb-chart"></canvas> 1461 </div> 1462 1463 <div class="p-card"> 1464 <div id="tb-list"></div> 1465 </div> 1466 </div> 1467 1468</div> 1469
1469<script src="../js/tankbuch.js"></script>
1469 1470 1471 <!-- Bottom Nav --> 1472 <nav id="bottom-nav"> 1473 <button class="nav-btn active has-longpress" data-view="list"> 1474 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1475 <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/> 1476 <rect x="9" y="3" width="6" height="4" rx="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="13" y2="16"/> 1477 </svg> 1478 Liste 1479 </button> 1480 <button class="nav-btn has-longpress" data-view="map"> 1481 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1482 <polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/> 1483 <line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/> 1484 </svg> 1485 Karte 1486 </button> 1487 <button class="nav-btn" data-view="route"> 1488 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1489 <circle cx="5" cy="18" r="2.5" stroke-width="2"/><circle cx="19" cy="6" r="2.5" stroke-width="2"/> 1490 <path d="M5 15.5V12c0-3 2-5 5-5h4c3 0 5-2 5-3.5"/> 1491 </svg> 1492 Route 1493 </button> 1494 <button class="nav-btn" data-view="scanner"> 1495 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1496 <circle cx="12" cy="12" r="2.2"/> 1497 <path d="M12 12 L12 4" stroke-dasharray="1 2.5"/> 1498 <path d="M4.5 16.5A9 9 0 0 1 12 3" stroke-dasharray="1.5 3"/> 1499 <path d="M19.5 16.5A9 9 0 0 0 12 3" stroke-dasharray="1.5 3"/> 1500 </svg> 1501 Scanner 1502 </button> 1503 <button class="nav-btn" data-view="tankbuch"> 1504 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1505 <path d="M14 3H6a1 1 0 00-1 1v16a1 1 0 001 1h9a1 1 0 001-1V9z"/> 1506 <path d="M15 3v5a1 1 0 001 1h5"/> 1507 <path d="M18 9v4a2 2 0 002 2v0a2 2 0 002-2V9l-2-3"/> 1508 </svg> 1509 Tankbuch 1510 </button> 1511 <button class="nav-btn" data-view="profile"> 1512 <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 1513 <circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/> 1514 </svg> 1515 <span id="nav-profile-label">Profil</span> 1516 </button> 1517 </nav> 1518</div> 1519 1520<!-- Loader & Toast --> 1521<div id="loader" class="show"><div class="spinner"></div></div> 1522<div id="toast"></div> 1523 1524<!-- Preismeldungs-Sheet --> 1525<div id="price-sheet-overlay"> 1526 <div id="price-sheet"> 1527 <button class="price-sheet-btn" id="ps-btn-confirm"></button> 1528 <!-- 04.09.2026 (User: "der Stepper ist gut gelöst. Dort ist aber die Schaltfläche 'Preis 1529 aktualisieren' überflüssig") - kein Umschalt-Button mehr, die Preiszeile ist jetzt immer 1530 sichtbar, sobald das Sheet offen ist. --> 1531 <div id="ps-price-input-row" class="open"> 1532 <div class="ps-stepper"> 1533 <button type="button" class="ps-step-btn" id="ps-step-minus" aria-label="Preis verringern">â</button> 1534 <input id="ps-price-input" type="text" inputmode="decimal" placeholder="z.B. 1.059"> 1535 <button type="button" class="ps-step-btn" id="ps-step-plus" aria-label="Preis erhöhen">+</button> 1536 </div> 1537 <button id="ps-price-submit">Melden</button> 1538 </div> 1539 <button class="price-sheet-btn danger" id="ps-btn-msg">â Nachricht an Admin</button> 1540 <div id="ps-msg-row"> 1541 <textarea id="ps-msg-input" rows="3" placeholder="Ihre Nachricht an den Adminâ¦" maxlength="500"></textarea> 1542 <button id="ps-msg-submit">Senden</button> 1543 </div> 1544 <button class="price-sheet-btn cancel" id="ps-btn-cancel">Abbrechen</button> 1545 </div> 1546</div> 1547 1548<!-- 05.09.2026 (User-Idee: "nach dem Erfolg und einem 'Danke!' wieder das grüne mit dem Button 1549 (ohne Hilfe) zeigen, dann kann daraus ein neues Update gestartet werden") - Wiederholungs- 1550 Overlay fuer den /mobile/q-Schnellsprung: nach erfolgreichem Preis-Update/-Bestaetigen im 1551 Quick-Jump-Kontext (?q) zeigt priceReport() statt der normalen Detail-Ansicht dieses Overlay 1552 (optisch identisch zu q.php: gruener Hintergrund, Blitz-Icon, "Preis melden"-Button, bewusst 1553 OHNE Anleitung) - ein Tap startet quickJumpToNearest() erneut fuer die naechste Tankstelle, 1554 ohne Umweg ueber Start/Suche/Liste. Ersetzt den (technisch auf iOS ohnehin unmoeglichen) 1555 Wunsch, das "Fenster" automatisch zu schliessen. --> 1556<div id="quickjump-again-overlay" hidden> 1557 <button type="button" id="qj-again-btn"> 1558 <svg viewBox="0 0 24 24" fill="#ffd60a"><path d="M13 2L4 14h6l-1 8 9-12h-6l1-8z"/></svg> 1559 <span>Preis melden</span> 1560 </button> 1561</div> 1562 1563<div class="wp-backdrop" id="wp-backdrop"></div> 1564<div class="wp-sheet" id="wp-sheet"> 1565 <div class="wp-sheet-title"> 1566 <span>Zwischenstopp</span> 1567 <button id="wp-sheet-close" aria-label="SchlieÃen">â</button> 1568 </div> 1569 <div class="route-field" style="margin-bottom:0"> 1570 <input id="wp-input" type="text" placeholder="Adresse eingebenâ¦" autocomplete="off"> 1571 </div> 1572 <div id="wp-sugg" class="route-suggestions"></div> 1573 <button id="wp-add-btn" disabled>Hinzufügen</button> 1574</div> 1575 1576<div id="route-print-sheet" class="rpp-sheet"></div> 1577<div id="list-print-sheet" class="rpp-sheet"></div> 1578
1579<script> 1580// 11.08.2026 User-Report: Beim Kaltstart vom Homescreen bleibt unten kurzzeitig ein Rand frei 1581// ("genau da, wo bei Safari die Steuerleiste liegt") - laut User-Beobachtung ein bekanntes 1582// WebKit-Problem: die Standalone-PWA rendert den allerersten Frame noch mit dem Platzverbrauch 1583// einer normalen Safari-Tableiste, bevor iOS auf den echten Vollbild-Viewport umschaltet 1584// (behoben u.a. durch echte Touch-Interaktion - #app per position:fixed+inset:0 reicht allein 1585// nicht, weil der von iOS gemeldete sichtbare Bereich in diesem Moment selbst noch falsch ist, 1586// nicht nur die CSS-Berechnung). Alter iOS-Trick zum Erzwingen einer Chrome-Neuberechnung ohne 1587// echte Beruehrung: programmatischer 1px-Scroll kurz nach dem Start. Kein garantierter Fix (echte 1588// Touch-Events werden von iOS teils anders behandelt als synthetische), aber schadet nicht. 1589window.addEventListener('load', () => { 1590 setTimeout(() => { window.scrollTo(0, 1); window.scrollTo(0, 0); }, 50); 1591}); 1592 1593// ââ Mapkit init ââ 1594const MAPKIT_TOKEN = 'eyJhbGciOiJFUzI1NiIsImtpZCI6Ik04MjlUOVE2WjgiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJHWTlRN0VXUzZNIiwiaWF0IjoxNzc0Nzc2Mzc3LCJleHAiOjE4MDYzMTIzNzcsIm9yaWdpbiI6IiouZ2FzLXRhbmtzdGVsbGVuLmRlIn0.mF6VqtlRtDMlRJVViOt9v36QJwOUe3R182JyOpllFGmHvbvjlP2rTsc6m2sxIZLt3o7P5P9LtDEvDsrX8D6yIg'; 1595mapkit.init({ authorizationCallback: done => done(MAPKIT_TOKEN) }); 1596 1597// ââ Apple Sign In init ââ 1598const APPLE_SERVICE_ID = 'de.gas-tankstellen.loginwithapple'; 1599const APPLE_REDIRECT_URI = 'https://www.gas-tankstellen.de/mobile/api/oauth_apple_callback.php'; 1600const GOOGLE_CLIENT_ID = '528440017242-6ldh4cdpujtbbt38j1qdis00nr4eh5ob.apps.googleusercontent.com'; 1601if (APPLE_SERVICE_ID) { 1602 AppleID.auth.init({ 1603 clientId: APPLE_SERVICE_ID, 1604 scope: 'name email', 1605 redirectURI: APPLE_REDIRECT_URI, 1606 state: encodeURIComponent(window.location.origin + '/mobile/'), 1607 usePopup: true, 1608 }); 1609} 1610 1611// 14.08.2026: detailMap/overviewMap/routeMap muessen VOR applyEffectiveTheme() deklariert sein, 1612// da die Funktion weiter unten (Zeile ~1326) sofort beim Laden aufgerufen wird - mit `let` weiter 1613// unten im Script fuehrte das zu einem ReferenceError (Temporal Dead Zone), der das komplette 1614// Skript abbrach und die App mit uebereinandergelegten Panels zurueckliess (Live-Vorfall, User: 1615// "ist auf dev total kaputt jetzt"). Die eigentlichen `let ...=null;`-Zeilen weiter unten im 1616// jeweiligen State-Block bleiben als reine Zuweisungen bestehen (ohne "let"). 1617let detailMap = null, overviewMap = null, routeMap = null; 1618 1619// ââ Theme (Hell/Dunkel/Auto, siehe Profil-View) ââ 1620// Fruehe Anwendung passiert bereits im <head> (siehe oben) - hier folgen nur noch die 1621// Umschalter-Buttons und der Auto-Listener fuer Systemwechsel waehrend die App offen ist. 1622function applyEffectiveTheme() { 1623 const t = localStorage.getItem('gasdb_theme') || 'auto'; 1624 const eff = t === 'auto' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : t; 1625 document.documentElement.setAttribute('data-theme', eff); 1626 document.querySelectorAll('[data-theme-choice]').forEach(b => b.classList.toggle('active', b.dataset.themeChoice === t)); 1627 // 14.08.2026: bereits offene AppleMaps live nachziehen, falls der Umschalter waehrend eine 1628 // Karte sichtbar ist betaetigt wird (colorScheme ist bei mapkit.Map auch nachtraeglich 1629 // setzbar, kein Neuaufbau der Karte noetig). 1630 const scheme = pwaMkColorScheme(); 1631 [detailMap, overviewMap, routeMap].forEach(m => { if (m) m.colorScheme = scheme; }); 1632} 1633// 14.08.2026 User-Wunsch ("AppleMap im Dark-Mode auch dark verwenden"): die PWA hat ein eigenes, 1634// von der klassischen Website unabhaengiges Theme-System (data-theme-Attribut, siehe oben) - alle 1635// mapkit.Map()-Aufrufe in der PWA lasen dieses bisher gar nicht aus, MapKit blieb also immer hell. 1636// Einfach vom bereits gesetzten data-theme-Attribut ablesen statt die Theme-Logik zu duplizieren. 1637function pwaMkColorScheme() { 1638 return document.documentElement.getAttribute('data-theme') === 'dark' 1639 ? mapkit.Map.ColorSchemes.Dark 1640 : mapkit.Map.ColorSchemes.Light; 1641} 1642document.querySelectorAll('[data-theme-choice]').forEach(btn => { 1643 btn.addEventListener('click', () => { 1644 localStorage.setItem('gasdb_theme', btn.dataset.themeChoice);
1645 applyEffectiveTheme(); 1646 }); 1647}); 1648window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 1649 if ((localStorage.getItem('gasdb_theme') || 'auto') === 'auto') applyEffectiveTheme(); 1650}); 1651applyEffectiveTheme(); 1652 1653// 05.09.2026 (User-Idee, nachdem mehrere Anlaeufe scheiterten, aus der bereits als Standalone 1654// laufenden Haupt-App heraus per Link/target="_blank" zuverlaessig echtes Safari zu erreichen - 1655// iOS' Verhalten dafuer ist offenbar grundsaetzlich flakey: "kannst Du per Klick den Link in die 1656// Zwischenablage kopieren und einen Hinweis geben, den in Safari einzufuegen?") - kopiert die 1657// /mobile/q-URL statt zu navigieren, kein Verlassen des Standalone-Containers mehr noetig. 1658document.getElementById('btn-qgo-copy')?.addEventListener('click', async () => { 1659 const url = document.getElementById('btn-qgo-copy').dataset.qgoUrl; 1660 try { 1661 await navigator.clipboard.writeText(url); 1662 showToast('Link kopiert! Ãffne Safari und füge ihn oben in die Adressleiste ein.', 4000); 1663 } catch (e) { 1664 showToast('Kopieren fehlgeschlagen. Adresse: ' + url, 5000); 1665 } 1666}); 1667 1668// ââ State ââ 1669let currentMode = localStorage.getItem('gasdb_mode') || 'lpg'; 1670let currentStations = []; 1671let currentAvgPrice = null; // Durchschnittspreis der aktuellen Trefferliste 1672detailMap = null; 1673overviewMap = null; 1674let userLat = null; 1675let userLng = null; 1676// 18.07.26: letzter Umkreissuche-Mittelpunkt, damit die Radius-Stufen-Buttons 1677// auf der Karte die Suche mit neuem Radius wiederholen koennen, unabhaengig 1678// davon ob der Standort ueber GPS, Long-Press oder Adresssuche kam. 1679// 18.09.2026 Merkmals-Filter der normalen Suche (siehe #search-filter-panel), per localStorage gemerkt 1680function searchFilters() { 1681 return [...document.querySelectorAll('.sf-cb:checked')].map(cb => cb.value); 1682} 1683function searchFilterParam() { 1684 const f = searchFilters(); 1685 return f.length ? '&filters=' + f.join(',') : ''; 1686} 1687function sfUpdateUi() { 1688 const n = searchFilters().length; 1689 document.getElementById('sf-count').textContent = n ? '(' + n + ')' : ''; 1690 document.getElementById('btn-search-filter').classList.toggle('active', n > 0); 1691 try { localStorage.setItem('searchFilters', JSON.stringify(searchFilters())); } catch (e) {} 1692} 1693document.getElementById('btn-search-filter').addEventListener('click', () => { 1694 const p = document.getElementById('search-filter-panel'); 1695 p.style.display = p.style.display === 'grid' ? 'none' : 'grid'; 1696}); 1697try { 1698 const sfSaved = JSON.parse(localStorage.getItem('searchFilters') || '[]'); 1699 document.querySelectorAll('.sf-cb').forEach(cb => { cb.checked = sfSaved.includes(cb.value); }); 1700} catch (e) {} 1701document.querySelectorAll('.sf-cb').forEach(cb => cb.addEventListener('change', () => { 1702 sfUpdateUi(); 1703 // bei sichtbarer Ergebnisliste sofort mit neuem Filter neu abfragen 1704 if ((activeView === 'list' || activeView === 'map') && lastSearchLat !== null && lastSearchLng !== null) { 1705 fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel); 1706 } 1707})); 1708sfUpdateUi(); 1709 1710let lastSearchLat = null; 1711let lastSearchLng = null; 1712let lastSearchLabel = null; 1713const RADIUS_STEPS = [2.5, 5, 7.5, 10, 20, 30]; 1714 1715// ââ Elemente ââ 1716const loader = document.getElementById('loader'); 1717const toast = document.getElementById('toast'); 1718const searchInput = document.getElementById('search-input'); 1719const views = { home: 'view-home', list: 'view-list', detail: 'view-detail', map: 'view-map', route: 'view-route', scanner: 'view-scanner', profile: 'view-profile', tankbuch: 'view-tankbuch', help: 'view-help' }; 1720let currentUser = null; // null = nicht eingeloggt 1721let activeFavTab = 'local'; 1722let profileFavCache = null; // gecachte Profil-Favoriten 1723let activeView = 'home'; 1724 1725// ââ Hilfsfunktionen ââ 1726function showLoader(v) { loader.classList.toggle('show', v); } 1727 1728// 26.09.2026 (User-Wunsch: "und doppelt so lange sichtbar") - Standarddauer 2500ms -> 5000ms. 1729// Aufrufer mit eigener, bewusst kuerzerer/laengerer Dauer (z.B. das kurze "Danke!" im 1730// Quick-Jump-Ablauf) sind davon nicht betroffen, die geben ihr eigenes ms explizit mit.
1731// type: 'success' (gruen) | 'error' (rot) | weggelassen (neutral dunkel, wie bisher). 1732function showToast(msg, ms = 5000, type) { 1733 toast.textContent = msg; 1734 toast.classList.toggle('toast--success', type === 'success'); 1735 toast.classList.toggle('toast--error', type === 'error'); 1736 toast.style.display = 'block'; 1737 clearTimeout(toast._t); 1738 toast._t = setTimeout(() => { toast.style.display = 'none'; }, ms); 1739} 1740 1741// 11.08.2026 User-Wunsch: eigener Toast am oberen Kartenrand (statt des globalen #toast über 1742// dem Header) für Karten-bezogene Hinweise wie "Auto-Folgen an/aus". 1743function showMapToast(msg, ms = 2000) { 1744 const el = document.getElementById('map-toast'); 1745 if (!el) return; 1746 el.textContent = msg; 1747 el.style.display = 'block'; 1748 clearTimeout(el._t); 1749 el._t = setTimeout(() => { el.style.display = 'none'; }, ms); 1750} 1751 1752function showView(name) { 1753 activeView = name; 1754 Object.values(views).forEach(id => { 1755 const el = document.getElementById(id); 1756 el.style.display = 'none'; 1757 }); 1758 const el = document.getElementById(views[name]); 1759 el.style.display = name === 'list' ? 'block' : 'flex'; 1760 document.querySelectorAll('.nav-btn').forEach(b => { 1761 b.classList.toggle('active', b.dataset.view === name || (name === 'detail' && b.dataset.view === 'list')); 1762 }); 1763 // Such- und Modusleiste bei Route-/Scanner-View ausblenden (Scanner braucht keine Textsuche, 1764 // die Kraftstoffart kommt weiterhin ueber die Modusleiste wie ueberall sonst) 1765 const searchBar = document.getElementById('search-bar'); 1766 const modeBar = document.getElementById('mode-bar'); 1767 if (searchBar) searchBar.style.display = (name === 'route' || name === 'scanner') ? 'none' : ''; 1768 // Filter-Panel gehoert zur Suchleiste: in Route/Scanner mit ausblenden (sonst hinge es allein da) 1769 if (name === 'route' || name === 'scanner') document.getElementById('search-filter-panel').style.display = 'none'; 1770 if (modeBar) modeBar.style.display = (name === 'route' || currentUser?.gas || localStorage.getItem('gasdb_gas_locked')) ? 'none' : ''; 1771 if (name === 'map') { 1772 initOverviewMap(); 1773 // Radius-Label auch synchronisieren, wenn die Karte ueber eine Text-Suche 1774 // (statt fetchNearby) erreicht wurde und dort noch nie gesetzt wurde. 1775 const radiusSel = document.getElementById('gps-radius'); 1776 if (radiusSel) updateMapRadiusUI(parseFloat(radiusSel.value)); 1777 // 11.08.2026: Auto-Modus-Timer nur waehrend die Karte sichtbar ist (Akku/GPS sparen). 1778 startMapAutoTracking(); 1779 } else { 1780 stopMapAutoTracking(); 1781 } 1782 if (name === 'route') loadRouteHistory(); 1783 // 27.07.26: kontinuierliches GPS-Tracking (watchPosition) nur waehrend die Scanner-View aktiv 1784 // ist starten/stoppen - spart Akku/Datenverbrauch in allen anderen Views. 1785 if (name === 'scanner') startScannerTracking(); else stopScannerTracking(); 1786 // 27.07.26 User-Wunsch: Header/Modusleiste/Bottom-Nav sollen sich dem immer-dunklen Scanner 1787 // anpassen, unabhaengig von der eigentlichen Hell/Dunkel/Auto-Wahl im Profil - beim Verlassen 1788 // wird die tatsaechliche Wahl wieder hergestellt (applyEffectiveTheme() ganz oben im Script). 1789 if (name === 'scanner') document.documentElement.setAttribute('data-theme', 'dark'); 1790 else applyEffectiveTheme(); 1791} 1792 1793// ââ Pull-to-Refresh (03.08.26, User-Wunsch) âââââââââââââââââââââââââââââââââââââââââââââââââ 1794// Nur fuer Views mit "aktualisierbaren" Daten (Liste/Karte/Profil-Favoriten) - bewusst NICHT fuer 1795// 'route' (eigene Drag-Interaktionen bei Wegpunkten) und 'scanner' (Kamera-/Kartengesten), um 1796// Gesten-Konflikte zu vermeiden. Fuer 'home'/'help'/'detail' gibt es nichts sinnvoll zu 1797// aktualisieren, daher dort ebenfalls kein Pull-Handler. 1798const PTR_THRESHOLD = 70; // px Ziehweite bis zum Ausloesen 1799const PTR_MAX = 110; // px maximale sichtbare Ziehweite (Indikator-Anschlag) 1800 1801function ptrScrollEl() { 1802 // 'map' bewusst ausgeschlossen (03.08.26, Live-Bug): MapKit JS hat eine eigene Touch-Pan-Geste, 1803 // die sich mit dem generischen Pull-Handler biss (Kartenscrollen loeste faelschlic
1803h PTR aus). 1804 if (activeView === 'list' || activeView === 'profile') { 1805 return document.getElementById(views[activeView]); 1806 } 1807 return null; 1808} 1809 1810async function ptrRefreshCurrentView() { 1811 if (activeView === 'list') { 1812 if (lastSearchLat !== null && lastSearchLng !== null) { 1813 await fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel); 1814 } 1815 } else if (activeView === 'profile') { 1816 profileFavCache = null; 1817 renderFavoritesHome(); 1818 } 1819} 1820 1821(function initPullToRefresh() { 1822 const indicator = document.getElementById('ptr-indicator'); 1823 let startY = null, pulling = false, triggered = false; 1824 1825 document.addEventListener('touchstart', e => { 1826 const scrollEl = ptrScrollEl(); 1827 if (!scrollEl || scrollEl.scrollTop > 0 || e.touches.length !== 1) { startY = null; return; } 1828 if (!scrollEl.contains(e.target)) { startY = null; return; } 1829 startY = e.touches[0].clientY; 1830 pulling = false; triggered = false; 1831 }, { passive: true }); 1832 1833 document.addEventListener('touchmove', e => { 1834 if (startY === null) return; 1835 const dy = e.touches[0].clientY - startY; 1836 if (dy <= 0) { return; } 1837 const scrollEl = ptrScrollEl(); 1838 if (!scrollEl || scrollEl.scrollTop > 0) { startY = null; indicator.classList.remove('ptr-visible'); return; } 1839 pulling = true; 1840 const dist = Math.min(dy * 0.5, PTR_MAX); 1841 indicator.style.transform = `translateY(${-40 + dist}px)`; 1842 indicator.classList.add('ptr-visible'); 1843 indicator.classList.toggle('ptr-spin', dist >= PTR_THRESHOLD); 1844 triggered = dist >= PTR_THRESHOLD; 1845 }, { passive: true }); 1846 1847 document.addEventListener('touchend', async () => { 1848 if (!pulling) { startY = null; return; } 1849 pulling = false; 1850 if (triggered) { 1851 indicator.style.transform = `translateY(${-40 + PTR_THRESHOLD}px)`; 1852 indicator.classList.add('ptr-visible', 'ptr-spin');
1853 try { await ptrRefreshCurrentView(); } catch(e) { /* Fehlermeldung uebernimmt fetchNearby selbst */ } 1854 } 1855 indicator.classList.remove('ptr-visible', 'ptr-spin'); 1856 indicator.style.transform = 'translateY(-40px)'; 1857 startY = null; triggered = false; 1858 }); 1859})(); 1860 1861function priceUnit(s) { 1862 // 08.09.26: Preise ausländischer Tankstellen stehen in Landeswährung 1863 // (tankstelle.lpg/cng), nicht in EUR â s.currency kommt aus der API 1864 // (mobile/api/search.php|nearby.php|route.php, via laender.inc.php). 1865 return (s && s.currency && s.currency !== 'EUR') ? s.currency : 'â¬'; 1866} 1867 1868function trendArrow(diff) { 1869 const d = parseFloat(diff); 1870 if (!d || isNaN(d)) return ''; 1871 return d > 0 1872 ? ` <span class="trend-up">â²</span>` 1873 : ` <span class="trend-down">â¼</span>`; 1874} 1875 1876function priceAge(ts) { 1877 if (!ts) return ''; 1878 const days = Math.floor((Date.now() / 1000 - ts) / 86400); 1879 if (days === 0) return 'heute'; 1880 if (days === 1) return 'gestern'; 1881 if (days < 7) return `vor ${days} Tagen`; 1882 if (days < 30) return `vor ${Math.floor(days/7)} Wo.`; 1883 return `vor ${Math.floor(days/30)} Mon.`; 1884} 1885 1886function gasIcon(gas) { 1887 if (gas === 'lpg') return { cls: 'icon-lpg', ch: 'â½' }; 1888 if (gas === 'cng') return { cls: 'icon-cng', ch: 'ðµ' }; 1889 return { cls: 'icon-both', ch: 'â½' }; 1890} 1891 1892// ââ Render Liste ââ 1893function renderList(stations, title) { 1894 currentStations = stations; 1895 // Durchschnittspreis einmalig berechnen (geteilt mit Karten-Pins) 1896 const prices = stations.map(stationPrice).filter(Boolean); 1897 currentAvgPrice = prices.length ? prices.reduce((a, b) => a + b, 0) / prices.length : null; 1898 1899 const items = document.getElementById('list-items'); 1900 const header = document.getElementById('list-title'); 1901 const count = document.getElementById('list-count'); 1902 header.textContent = title || 'Ergebnisse'; 1903 count.textContent = `${stations.length} Tankstellen`; 1904 items.innerHTML = ''; 1905 1906 document.getElementById('btn-list-print').classList.toggle('show', stations.length > 0); 1907 document.getElementById('list-print-sheet').innerHTML = !stations.length ? '' : ` 1908 <h1>${esc(title || 'Ergebnisse')}</h1> 1909 <div class="rpp-meta">${stations.length} Tankstelle${stations.length !== 1 ? 'n' : ''} 1910 · ${currentMode === 'cng' ? 'CNG' : 'LPG'} · gedruckt am ${new Date().toLocaleDateString('de-DE')}</div> 1911 <table> 1912 <thead><tr><th></th><th>Tankstelle</th><th>Adresse</th><th>Entf.</th><th>Preis</th><th>Info</th></tr></thead> 1913 <tbody> 1914 ${stations.map((s, i) => { 1915 const price = stationPrice(s); 1916 const addr = [s.strasse, [s.plz, s.ort].filter(Boolean).join(' ')].filter(Boolean).join(', '); 1917 const dist = s.abfahrt !== undefined 1918 ? (s.bab_direkt ? 'direkt' : `Ausf. ${s.abfahrt != null ? s.abfahrt : 'â'}`) 1919 : (s.dist != null ? `${s.dist} km` : 'â'); 1920 const info = s.h24 ? '24h' : ''; 1921 return `<tr> 1922 <td><span class="rpp-num">${i+1}</span></td> 1923 <td>${esc(s.name)}</td> 1924 <td>${esc(addr)}</td> 1925 <td>${esc(dist)}</td> 1926 <td>${price ? price.toFixed(3)+' '+priceUnit(s)+'/L' : 'â'}</td> 1927 <td>${esc(info)}</td> 1928 </tr>`; 1929 }).join('')} 1930 </tbody> 1931 </table>`; 1932 1933 if (!stations.length) { 1934 items.innerHTML = '<div style="padding:32px;text-align:center;color:#6b7280">Keine Tankstellen gefunden.<br>Suchradius oder Filter anpassen.</div>'; 1935 return; 1936 } 1937 1938 stations.forEach((s, i) => { 1939 const ic = gasIcon(s.gas); 1940 const card = document.createElement('div'); 1941 card.className = 'station-card' + (s.bab_direkt ? ' station-card-bab' : ''); 1942 1943 const lpgTag = currentMode === 'lpg' 1944 ? (s.lpg 1945 ? `<span class="price-tag price-lpg">LPG ${s.lpg.toFixed(3)} ${priceUnit(s)}${trendArrow(s.lpg_diff)}${s.lpg_stand ? ` · ${priceAge(s.lpg_stand)}` : ''}</span>` 1946 : (s.gas === 'lpg' || s.gas === 'beides' ? '<span class="price-tag price-none">LPG k.A.</span>' : '')) 1947 : ''; 1948 const cngTag = currentMode === 'cng' 1949 ? (s.cng 1950 ? `<span class="price-tag price-cng">CNG ${s.cng.toFixed(3)} ${priceUnit(s)}${trendArrow(s.cng_diff)}${s.cng_stand ? ` · ${priceAge(s.cng_stand)}` : ''}</span>` 1951 : (s.gas === 'cng' || s.gas === 'beides' ? '<span class="price-tag price-none">CNG k.A.</span>' : '')) 1952 : ''; 1953 1954 // Autobahn-Suche liefert abfahrt/abfahrt_text/entfernung statt GPS-Distanz 1955 const isAutobahn = s.abfahrt !== undefined; 1956 let metaHtml; 1957 const h24Badge = s.h24 ? `<div class="h24-badge">â° 24h</div>` : ''; 1958 1959 if (isAutobahn) { 1960 // Bei Autobahntankstellen (Entfernung=0, direkt an der Strecke) ist die 1961 // Ausfahrt-Nummer irrelevant -> nicht anzeigen, nur "direkt". 1962 metaHtml = s.bab_direkt 1963 ? `<div class="station-meta"> 1964 <div class="dist-badge">ð£ï¸</div> 1965 <div class="dist-unit">direkt</div> 1966 ${h24Badge} 1967 </div>` 1968 : `<div class="station-meta"> 1969 <div class="dist-badge">Ausf. ${s.abfahrt != null ? s.abfahrt : 'â'}</div> 1970 <div class="dist-unit">${s.entfernung} km</div> 1971 ${h24Badge} 1972 </div>`; 1973 var babInfo = `<div class="bab-ausfahrt">${s.bab_direkt ? '<span class="bab-badge">ð£ï¸ Autobahntankstelle</span>' : esc(s.abfahrt_text || '')}</div>`; 1974 } else { 1975 const distText = s.dist != null ? `${s.dist}` : 'â'; 1976 metaHtml = `
1977 <div class="station-meta"> 1978 <div class="dist-badge">${distText}</div> 1979 <div class="dist-unit">km</div> 1980 ${h24Badge} 1981 </div>`; 1982 var babInfo = ''; 1983 } 1984 1985 card.innerHTML = ` 1986 <div class="station-icon" style="background:${pinColor(stationPrice(s), currentAvgPrice)}">â½</div> 1987 <div class="station-body"> 1988 <div class="station-name">${esc(s.name)}</div> 1989 <div class="station-addr">${s.strasse ? esc(s.strasse) + ' · ' : ''}${esc(s.plz)} ${esc(s.ort)}</div> 1990 ${babInfo} 1991 <div class="station-prices">${lpgTag}${cngTag}</div> 1992 ${featBadges(s) ? `<div class="feat-badges" style="margin-top:5px">${featBadges(s)}</div>` : ''} 1993 </div> 1994 ${metaHtml}`; 1995 card.addEventListener('click', () => showDetail(s)); 1996 items.appendChild(card); 1997 }); 1998 1999 showView('list'); 2000} 2001 2002document.getElementById('btn-list-print').addEventListener('click', () => printSheet('list-print-sheet')); 2003 2004// 10.08.26 Bugfix (User-Report: "AM iPhone hat Drucken keinerlei Funktion"): window.print() ist 2005// im iOS-Standalone-Modus (App vom Home-Bildschirm gestartet) ein stiller No-Op - Apple bindet 2006// den Druckdialog an die Safari-UI, die im Standalone-Container nicht existiert. In normalem 2007// Safari (Browser-Tab) oder als installierte PWA auf Android/Desktop funktioniert window.print() 2008// dagegen ganz normal. Fix nur fuer den iOS-Standalone-Fall: den fertig gebauten Druck-Sheet-Inhalt 2009// (siehe .rpp-sheet/@media print oben) ueber localStorage an eine eigene, schlanke Seite 2010// (print.php) uebergeben und die per window.open(...,'_blank') in echtem Safari oeffnen - das 2011// verlaesst zuverlaessig den Standalone-Container (anders als eine normale Same-Origin-Navigation, 2012// die im Container bleibt), und dort funktioniert window.print() wieder normal. print.php ruft 2013// window.print() selbst automatisch beim Laden auf. 2014function printSheet(sheetElId) { 2015 const isIosStandalone = window.navigator.standalone === true; 2016 if (!isIosStandalone) { window.print(); return; } 2017 const html = document.getElementById(sheetElId).innerHTML; 2018 try { 2019 localStorage.setItem('gasdb_print_payload', html); 2020 } catch (e) { 2021 alert('Drucken nicht möglich (Speicher voll). Bitte die Seite in Safari öffnen und dort erneut versuchen.'); 2022 return; 2023 } 2024 window.open('print.php', '_blank'); 2025} 2026 2027function esc(s) { 2028 return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 2029} 2030 2031// ââ Detail ââ 2032function showDetail(s) { 2033 document.getElementById('detail-name').textContent = s.name; 2034 document.getElementById('detail-addr').textContent = `${s.strasse}, ${s.plz} ${s.ort}`; 2035 2036 // Preise â nur passend zum aktuellen Modus anzeigen 2037 const hasCurrentPrice = (currentMode === 'lpg' && s.lpg) || (currentMode === 'cng' && s.cng); 2038 let pricesHtml = ''; 2039 if (currentMode === 'lpg' && (s.gas === 'lpg' || s.gas === 'beides')) { 2040 pricesHtml += `<div class="detail-price-box lpg"> 2041 <div class="gas-label">LPG AutoGas</div> 2042 <div class="gas-price">${s.lpg ? s.lpg.toFixed(3) + ' ' + priceUnit(s) : 'k.A.'}</div> 2043 <div class="gas-age">${s.lpg ? priceAge(s.lpg_stand) : ''}</div> 2044 </div>`; 2045 } 2046 if (currentMode === 'cng' && (s.gas === 'cng' || s.gas === 'beides')) { 2047 pricesHtml += `<div class="detail-price-box cng"> 2048 <div class="gas-label">CNG Erdgas</div> 2049 <div class="gas-price">${s.cng ? s.cng.toFixed(3) + ' ' + priceUnit(s) : 'k.A.'}</div> 2050 <div class="gas-age">${s.cng ? priceAge(s.cng_stand) : ''}</div> 2051 </div>`; 2052 } 2053 // 04.09.2026 (User-Idee: "rechts daneben ein Quadrat gleicher Höhe mit Check-Haken als 2054 // schnelle Preis-Bestätigung") - eigenes Quadrat neben der Preis-Box, bestätigt den Preis mit 2055 // einem Tap statt erst das ganze Preismeldungs-Sheet zu öffnen. Nur wenn eingeloggt und ein 2056 // Preis vorhanden ist (nichts zu bestätigen, wenn "k.A."). 2057 if (currentUser && hasCurrentPrice) { 2058 pricesHtml += `<button type="button" class="detail-price-confirm-btn" id="btn-quick-confirm" title="Preis bestätigen" aria-label="Preis bestätigen"> 2059 <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 12 9 17 20 6"/></svg> 2060 </button>`; 2061 } 2062 document.getElementById('detail-prices').innerHTML = pricesHtml; 2063 2064 // Preis-Box anklickbar wenn eingeloggt â direkt Preiseingabe öffnen 2065 if (currentUser) { 2066 document.querySelectorAll('.detail-price-box').forEach(box => { 2067 box.style.cursor = 'pointer'; 2068 box.addEventListener('click', () => { 2069 openPriceSheet(s); 2070 document.getElementById('ps-msg-row').classList.remove('open'); 2071 // 04.09.2026: kein automatischer .focus() mehr, siehe initPriceStepper()-Kommentar. 2072 // Preiszeile ist inzwischen immer offen, kein .add('open') mehr noetig. 2073 }); 2074 }); 2075 document.getElementById('btn-quick-confirm')?.addEventListener('click', () => quickConfirmPrice(s)); 2076 } 2077 2078 // Infos 2079 // 05.09.2026 (User: "kannst du den Bereich 2-spaltig machen? 7tage & Ãffnungszeiten / Telefon 2080 // & Entfernung / links & rechts-bündig") - alle Fakten einheitlich als row() in ein 2081 // 2-spaltiges CSS-Grid, das sie in Erzeugungsreihenfolge automatisch paart (siehe CSS: 2082 // #detail-infos), linke Spalte linksbündig mit Icon vorne, rechte Spalte rechtsbündig mit 2083 // Icon hinten (:nth-child(2n)). 2084 // 18.09.2026 (User: "der LKW ist bei #26543 verschwunden" - Icon-only-Zeilen im Grid waren zu 2085 // klein/unauffaellig; "reine Badges für EC/KK und LKW die wenig Platz brauchen") - giro/kk/lkw
2086 // laufen jetzt als kompakte Pill-Badges statt als row()-Zeilen, siehe featBadges(). 2087 document.getElementById('detail-badges').innerHTML = featBadges(s); 2088 2089 let infos = ''; 2090 if (s.h24) infos += row('ð', '24h'); 2091 if (s.d7) infos += row('ð ', '7 Tage/Woche'); 2092 if (s.oez && !s.h24) infos += row('ð', esc(s.oez)); 2093 if (s.tel) { 2094 // tel: nur mit erlaubten Zeichen â verhindert javascript:-Protokoll-Injection 2095 const telSafe = String(s.tel).replace(/[^0-9+\-() \/]/g, ''); 2096 if (telSafe) infos += row('ð', `<a href="tel:${telSafe}" style="color:var(--blue)">${esc(s.tel)}</a>`); 2097 } 2098 if (s.dist) infos += row('ð', `${s.dist} km entfernt`); 2099 if (s.abfahrt !== undefined) { 2100 if (s.bab_direkt) { 2101 infos += row('ð£ï¸', '<b>Autobahntankstelle</b> â direkt an der Strecke'); 2102 } else { 2103 infos += row('ð£ï¸', `Ausfahrt ${esc(String(s.abfahrt ?? ''))} ${esc(s.abfahrt_text || '')} â ${s.entfernung} km entfernt`); 2104 } 2105 } 2106 document.getElementById('detail-infos').innerHTML = infos; 2107 2108 // Buttons 2109 const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 2110 const isAndroid = /Android/.test(navigator.userAgent); 2111 // 11.08.2026 Bugfix (User-Report: "Navigieren" auf Android ohne Funktion, iOS problemlos): 2112 // das geo:-URI-Schema wird aus einem Browser/einer PWA heraus (anders als aus einer echten 2113 // nativen App) auf Android oft nicht zuverlaessig an eine Karten-App weitergereicht - Google 2114 // empfiehlt fuer Web-Kontexte explizit die HTTPS-"Universal"-URL statt geo: (siehe 2115 // developers.google.com/maps/documentation/urls/get-started). 2116 const geoUrl = isIOS 2117 ? `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d` 2118 : isAndroid 2119 ? `https://www.google.com/maps/dir/?api=1&destination=${s.lat},${s.lng}` 2120 : `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d`; 2121 const favActive = isFavorite(s.id) ? ' active' : ''; 2122 const profActive = isProfileFavorite(s.id) ? ' active' : ''; 2123 const profBtn = currentUser 2124 ? `<button class="action-btn fav-profile${profActive}" id="btn-profile-fav" title="Im Profil speichern">â </button>` 2125 : ''; 2126 // 05.09.2026 (User: "in der Detail-Ansicht ist der graue Button zum Preisupdate nicht mehr 2127 // nötig") - reportBtn (±â) war ein separater Weg zum Preismeldungs-Sheet, seit der Preiseingabe- 2128 // Ãberarbeitung vom Vortag aber redundant: die Preis-Box selbst öffnet das Sheet per Klick 2129 // (auch ohne vorhandenen Preis, siehe unten), das Quick-Bestätigen-Quadrat übernimmt die 2130 // schnelle Bestätigung. hasPrice bleibt fuer das Quick-Bestätigen-Quadrat weiterhin nötig. 2131 const hasPrice = (currentMode === 'lpg' && s.lpg) || (currentMode === 'cng' && s.cng); 2132 document.getElementById('detail-actions').innerHTML = ` 2133 <a href="${geoUrl}" class="action-btn nav"> 2134 <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> 2135 <polygon points="3 11 22 2 13 21 11 13 3 11"/> 2136 </svg> Navigieren 2137 </a> 2138 <button class="action-btn fav${favActive}" id="btn-fav-toggle" title="Lokal speichern">â¥</button> 2139 ${profBtn}`; 2140 document.getElementById('btn-fav-toggle').addEventListener('click', () => toggleFavorite(s)); 2141 if (currentUser) { 2142 document.getElementById('btn-profile-fav').addEventListener('click', async () => { 2143 if (isProfileFavorite(s.id)) { 2144 await removeProfileFavorite(s.id, null); 2145 updateProfileFavBtn(s.id, false); 2146 showToast('Aus Profil entfernt'); 2147 } else { 2148 await addProfileFavorite(s); 2149 } 2150 }); 2151 } 2152 2153 showView('detail'); 2154 2155 // Detail-Karte 2156 setTimeout(() => { 2157 if (detailMap) { detailMap.destroy(); detailMap = null; } 2158 const coord = new mapkit.Coordinate(s.lat, s.lng); 2159 detailMap = new mapkit.Map('detail-map', { 2160 center: coord, 2161 region: new mapkit.CoordinateRegion(coord, new mapkit.CoordinateSpan(0.005, 0.005)), 2162 showsCompass: mapkit.FeatureVisibility.Hidden, 2163 showsZoomControl: false, showsMapTypeControl: false, 2164 showsUserLocation: true, 2165 colorScheme: pwaMkColorScheme(), 2166 }); 2167 detailMap.addAnnotation(new mapkit.MarkerAnnotation(coord, {
2168 title: s.name, color: '#1D4CA3', glyphText: 'â½' 2169 })); 2170 }, 50); 2171} 2172 2173function row(icon, text) { 2174 return `<div class="detail-info-row"><span class="detail-info-icon">${icon}</span><span>${text}</span></div>`; 2175} 2176 2177// 18.09.2026 (User: "reine Badges für EC/KK und LKW die wenig Platz brauchen... brauchen wir ja 2178// dann auch für die Liste") - kompakte Pill-Badges statt Zeilen im 2-spaltigen Infos-Grid, 2179// gemeinsam genutzt von Detail- und Listenansicht. 2180function featBadges(s) { 2181 let h = ''; 2182 if (s.lkw) h += `<span class="feat-badge feat-badge--lkw" title="LKW-geeignet">ð LKW</span>`; 2183 if (s.giro) h += `<span class="feat-badge feat-badge--giro" title="EC/GiroCard">ð³ EC</span>`; 2184 if (s.kk) h += `<span class="feat-badge feat-badge--kk" title="Kreditkarte (Mastercard/Visa)">ð³ KK</span>`; 2185 return h; 2186} 2187 2188// ââ Offline-Cache der letzten Ergebnisliste (User-Wunsch: "Daten der gefundenen TS im 2189// localStorage halten, falls keine Datenverbindung vorhanden ist") - wird nach jeder 2190// erfolgreichen Umkreis-/Text-Suche ueberschrieben, bei einem Netzwerkfehler in fetchNearby()/ 2191// fetchSearch() als Fallback angezeigt statt nur einer Fehlermeldung. ââ 2192const OFFLINE_CACHE_KEY = 'gasdb_last_results'; 2193 2194function saveOfflineCache(stations, title) { 2195 if (!stations || !stations.length) return; 2196 try { 2197 localStorage.setItem(OFFLINE_CACHE_KEY, JSON.stringify({ stations, title, mode: currentMode, ts: Date.now() })); 2198 } catch (e) { /* z.B. Speicher voll - kein kritischer Fehler, Cache bleibt einfach alt */ } 2199} 2200 2201function loadOfflineCache() { 2202 try { 2203 const raw = localStorage.getItem(OFFLINE_CACHE_KEY); 2204 return raw ? JSON.parse(raw) : null; 2205 } catch (e) { return null; } 2206} 2207 2208// Zeigt die gecachten Ergebnisse an, falls vorhanden - true bei Erfolg, sonst false (dann bleibt 2209// die normale Fehlermeldung im Aufrufer bestehen). 2210function tryOfflineFallback() { 2211 const cache = loadOfflineCache(); 2212 if (!cache || !cache.stations || !cache.stations.length) return false; 2213 const ageMin = Math.round((Date.now() - cache.ts) / 60000); 2214 const ageTxt = ageMin < 1 ? 'gerade eben' : ageMin < 60 ? `vor ${ageMin} Min.` : `vor ${Math.round(ageMin / 60)} Std.`; 2215 renderList(cache.stations, cache.title); 2216 showToast(`Keine Verbindung â zeige letzte Ergebnisse (${ageTxt})`); 2217 return true; 2218} 2219 2220// Dauerhafter, dezenter OFFLINE-Stempel im Header (User-Wunsch), nicht nur ein einmaliger Toast - 2221// reagiert live auf online/offline-Events UND prueft den Zustand einmal beim Laden. 2222function updateOfflineBanner() { 2223 const badge = document.getElementById('offline-badge'); 2224 if (badge) badge.style.display = navigator.onLine === false ? 'inline-block' : 'none'; 2225} 2226window.addEventListener('online', updateOfflineBanner); 2227window.addEventListener('offline', updateOfflineBanner); 2228updateOfflineBanner(); 2229 2230// 11.09.2026 (User-Fund: aus dem Hintergrund reaktivierte PWA zeigt ohne vollstaendigen 2231// Neustart weiter den alten Stand, "das verwirrt den normalen User denke ich") - kein Service 2232// Worker vorhanden, daher hier ein leichtgewichtiger Eigenbau: Boot-Version (vom Server 2233// gerendert) wird bei jedem "Seite wieder sichtbar"-Ereignis gegen mobile/api/version.php 2234// verglichen; bei Abweichung erscheint ein Banner statt kommentarlos veraltet zu bleiben. 2235// Bewusst KEIN automatisches Reload (koennte mitten in einer Eingabe/Suche stoeren) - der 2236// User entscheidet per Klick, wann er aktualisiert. 2237const BOOT_VERSION = "26.10.01-4"; 2238let _updateBannerShown = false; 2239 2240async function checkForUpdate() { 2241 if (_updateBannerShown) return; 2242 try { 2243 const res = await fetch('api/version.php', { cache: 'no-store' }); 2244 const data = await res.json(); 2245 if (data.version && data.version !== BOOT_VERSION) { 2246 _updateBannerShown = true; 2247 document.getElementById('update-banner').style.display = 'block'; 2248 } 2249 } catch (e) { /* kein Netz o.ae. - kein kritischer Fehler, naechster Versuch reicht */ } 2250} 2251document.addEventListener('visibilitychange', () => { 2252 if (document.visibilityState === 'visible') checkForUpdate(); 2253}); 2254 2255// ââ Favoriten (lokal, max. 10, localStorage) ââ 2256const FAV_KEY = 'gasdb_favorites'; 2257const FAV_MAX = 10; 2258 2259function getFavorites() { 2260 try { 2261 const raw = localStorage.getItem(FAV_KEY); 2262 return raw ? JSON.parse(raw) : []; 2263 } catch (e) { return []; } 2264} 2265 2266function saveFavorites(list) { 2267 localStorage.setItem(FAV_KEY, JSON.stringify(list)); 2268} 2269 2270function isFavorite(id) { 2271 return getFavorites().some(f => f.id === id); 2272} 2273 2274function toggleFavorite(s) { 2275 let favs = getFavorites(); 2276 const idx = favs.findIndex(f => f.id === s.id); 2277 if (idx >= 0) { 2278 favs.splice(idx, 1); 2279 showToast('Favorit entfernt'); 2280 } else { 2281 if (favs.length >= FAV_MAX) { 2282 showToast(`Maximal ${FAV_MAX} Favoriten möglich`); 2283 return; 2284 } 2285 favs.push(s); 2286 showToast('Als Favorit gespeichert'); 2287 } 2288 saveFavorites(favs); 2289 const btn = document.getElementById('btn-fav-toggle'); 2290 if (btn) btn.classList.toggle('active', isFavorite(s.id)); 2291 renderFavoritesHome(); 2292} 2293 2294function removeFavorite(id, ev) { 2295 if (ev) ev.stopPropagation(); 2296 saveFavorites(getFavorites().filter(f => f.id !== id)); 2297 renderFavoritesHome(); 2298} 2299 2300function favPriceTag(f) { 2301 const tag = (val, stand, diff, cls, label) => val 2302 ? `<span class="price-tag ${cls}">${label} ${val.toFixed(3)} ${priceUnit(f)}${trendArrow(diff)} · ${priceAge(stand)}</span>` 2303 : (f.gas === label.toLowerCase() || f.gas === 'beides' ? `<span class="price-tag price-none">${label} k.A.</span>` : ''); 2304 if (currentMode === 'lpg') return tag(f.lpg, f.lpg_stand, f.lpg_diff, 'price-lpg', 'LPG'); 2305 if (currentMode === 'cng') return tag(f.cng, f.cng_stand, f.cng_diff, 'price-cng', 'CNG'); 2306 return tag(f.lpg, f.lpg_stand, f.lpg_diff, 'price-lpg', 'LPG') + tag(f.cng, f.cng_stand, f.cng_diff, 'price-cng', 'CNG'); 2307} 2308 2309function renderFavRow(f) { 2310 return ` 2311 <div class="fav-row" onclick='showDetail(${JSON.stringify(f).replace(/'/g, "'")})'> 2312 <div class="fav-row-body"> 2313 <div class="fav-row-name">${esc(f.name)}</div> 2314 <div class="fav-row-addr">${f.strasse ? esc(f.strasse) + ' · ' : ''}${esc(f.plz)} ${esc(f.ort)}</div> 2315 <div class="fav-row-prices">${favPriceTag(f)}</div> 2316 </div> 2317 <button class="fav-row-remove" onclick="removeFavorite(${f.id}, event)">â</button> 2318 </div>`; 2319} 2320 2321function renderFavoritesHome() { 2322 if (activeFavTab === 'profile' && currentUser) { renderProfileFavorites(); return; } 2323 const favs = getFavorites(); 2324 const el = document.getElementById('fav-section'); 2325 if (!favs.length) { el.innerHTML = ''; return; } 2326 el.innerHTML = `<h3>⥠Lokale Favoriten (${favs.length}/${FAV_MAX})</h3>` + favs.map(renderFavRow).join(''); 2327 2328 // Frische Preise nachladen (Snapshot beim Favorisieren kann veraltet sein). 2329 // Stationen, die der Server nicht mehr liefert (gesperrt, geloescht, oder 2330 // nachtraeglich "Versorger-non-grata" geworden -> vers_lpg < 0), werden 2331 // automatisch aus den Favoriten entfernt. 2332 const ids = favs.map(f => f.id).join(','); 2333 fetch(`api/favorites.php?ids=${ids}`) 2334 .then(r => r.json()) 2335 .then(data => { 2336 if (!data.ok) return; 2337 const updated = getFavorites() 2338 .map(f => { 2339 const fresh = data.stations.find(x => x.id === f.id); 2340 return fresh ? { ...f, lpg: fresh.lpg, cng: fresh.cng, lpg_stand: fresh.lpg_stand, cng_stand: fresh.cng_stand, lpg_diff: fresh.lpg_diff, cng_diff: fresh.cng_diff } : null; 2341 }) 2342 .filter(f => f !== null); 2343 saveFavorites(updated); 2344 el.innerHTML = updated.length 2345 ? `<h3>⥠Lokale Favoriten (${updated.length}/${FAV_MAX})</h3>` + updated.map(renderFavRow).join('') 2346 : ''; 2347 }) 2348 .catch(() => {}); // offline o.ä. â Snapshot bleibt unangetastet sichtbar 2349} 2350 2351// ââ Profil-Favoriten (Server) ââââââââââââââââââââââââââââââââââââââââââââââââ 2352 2353function renderProfileFavRow(f) { 2354 return ` 2355 <div class="fav-row" onclick='showDetail(${JSON.stringify(f).replace(/'/g, "'")})'> 2356 <div class="fav-row-body"> 2357 <div class="fav-row-name">${esc(f.name)}</div> 2358 <div class="fav-row-addr">${f.strasse ? esc(f.strasse) + ' · ' : ''}${esc(f.plz)} ${esc(f.ort)}</div> 2359 <div class="fav-row-prices">${favPriceTag(f)}</div> 2360 </div> 2361 <button class="fav-row-remove" onclick="removeProfileFavorite(${f.id}, event)">â</button> 2362 </div>`; 2363} 2364 2365async function renderProfileFavorites() { 2366 const el = document.getElementById('fav-section'); 2367 if (!profileFavCache) { 2368 el.innerHTML = '<p style="padding:12px 4px;color:var(--sub);font-size:.85rem">Lade â¦</p>'; 2369 try { 2370 const r = await fetch(`api/favorites.php?action=list&mode=${currentMode}`); 2371 const d = await r.json(); 2372 profileFavCache = d.ok ? d.stations : []; 2373 } catch(e) { profileFavCache = []; } 2374 } 2375 if (!profileFavCache.length) { 2376 el.innerHTML = '<p style="padding:12px 4px;color:var(--sub);font-size:.85rem">Keine Profil-Favoriten gespeichert.</p>'; 2377 return; 2378 } 2379 el.innerHTML = `<h3>â Profil-Favoriten (${profileFavCache.length})</h3>` + profileFavCache.map(renderProfileFavRow).join(''); 2380} 2381 2382async function addProfileFavorite(s) { 2383 if (!currentUser) return; 2384 const fd = new FormData(); 2385 fd.append('action', 'add'); fd.append('tankid', s.id); 2386 const r = await fetch('api/favorites.php', { method: 'POST', body: fd }); 2387 const d = await r.json(); 2388 if (d.ok) { 2389 profileFavCache = null; // Cache invalidieren 2390 showToast('Im Profil gespeichert'); 2391 updateProfileFavBtn(s.id, true); 2392 } 2393} 2394 2395async function removeProfileFavorite(tankid, ev) { 2396 if (ev) ev.stopPropagation(); 2397 const fd = new FormData(); 2398 fd.append('action', 'remove'); fd.append('tankid', tankid); 2399 await fetch('api/favorites.php', { method: 'POST', body: fd }); 2400 profileFavCache = null; 2401 renderFavoritesHome(); 2402} 2403 2404function isProfileFavorite(id) { 2405 return profileFavCache ? profileFavCache.some(f => f.id === id) : false;
2406} 2407 2408function updateProfileFavBtn(id, active) { 2409 const btn = document.getElementById('btn-profile-fav'); 2410 if (btn) btn.classList.toggle('active', active); 2411} 2412 2413// Tab-Handler 2414document.querySelectorAll('.fav-tab').forEach(btn => { 2415 btn.addEventListener('click', () => { 2416 activeFavTab = btn.dataset.tab; 2417 document.querySelectorAll('.fav-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === activeFavTab)); 2418 renderFavoritesHome(); 2419 }); 2420}); 2421 2422// ââ Preisfarbe für Karten-Pins ââ 2423function stationPrice(s) { 2424 // Relevanten Preis je nach Modus wählen; bei 'all' LPG bevorzugen 2425 return currentMode === 'cng' ? s.cng : (s.lpg ?? s.cng); 2426} 2427 2428// 19.07.26 User-Wunsch: statt fester Farbstufen ein fliessender Verlauf - je guenstiger, desto 2429// heller/leuchtender das Gruen, je teurer, desto dunkler das Rot. Ausserhalb der Durchschnitts- 2430// Zone (±2ct, bleibt blau wie zuvor) wird die Abweichung vom Schnitt auf 0..1 normiert 2431// (voll gesaettigt ab ±8ct) und zwischen zwei Ankerfarben interpoliert. 2432function _lerpChannel(a, b, t) { return Math.round(a + (b - a) * t); } 2433function _lerpHexColor(hex1, hex2, t) { 2434 const toRgb = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; 2435 const [r1, g1, b1] = toRgb(hex1); 2436 const [r2, g2, b2] = toRgb(hex2); 2437 return `rgb(${_lerpChannel(r1, r2, t)},${_lerpChannel(g1, g2, t)},${_lerpChannel(b1, b2, t)})`; 2438} 2439 2440function pinColor(price, avg) { 2441 if (!price || !avg) return '#e5e7eb'; // kein Preis â hellgrau 2442 const diff = price - avg; // positiv = teurer als Durchschnitt 2443 const AVG_BAND = 0.02; // ±2ct gilt weiterhin als "im Durchschnitt" 2444 const MAX_SPREAD = 0.08; // ab ±8ct voll gesaettigte Farbe 2445 if (Math.abs(diff) <= AVG_BAND) return '#3b82f6'; // Durchschnitt: mittelblau, wie zuvor 2446 2447 const t = Math.min(1, (Math.abs(diff) - AVG_BAND) / (MAX_SPREAD - AVG_BAND)); 2448 if (diff < 0) { 2449 // günstiger als Durchschnitt: Grün, je billiger desto heller 2450 return _lerpHexColor('#16a34a', '#86efac', t); // dunkleres â helles/leuchtendes Grün 2451 } 2452 // teurer als Durchschnitt: Rot, je teurer desto dunkler 2453 return _lerpHexColor('#fca5a5', '#7f1d1d', t); // helles â dunkles Rot 2454} 2455 2456// ââ Scanner (27.07.26, Integration des bisher isolierten mobile/scanner.php-Prototyps - 2457// User-Idee: "Tankstellen-Radar", zeigt Stationen im Sektor voraus per GPS-Kurs/Kompass an, 2458// siehe Memory project_pwa_scanner. Nutzt bewusst dieselben pinColor()/stationPrice()/ 2459// currentMode wie der Rest der App statt eigener Kopien (Unterschied zum eigenstaendigen 2460// Prototyp, der noch alles dupliziert hatte). ââ 2461const SC_SECTOR_HALF = 60; // Grad, Halb-Oeffnungswinkel des Sektors voraus 2462const SC_MOVING_MIN_SPEED = 1.0; // m/s (~3.6 km/h) - darunter gilt GPS-Kurs als unzuverlaessig 2463let scRangeKm = 5; 2464let scUserLat = null, scUserLng = null, scHeading = null, scHeadingSource = null; 2465let scStations = []; 2466let scLastFetchLat = null, scLastFetchLng = null; 2467let scSelectedBlip = null; 2468let scSweepAngle = -SC_SECTOR_HALF, scSweepDir = 1; 2469let scWatchId = null; 2470let scDrawing = false; 2471let scListInterval = null, scStatusInterval = null; 2472 2473const scCanvas = document.getElementById('sc-radar'); 2474const scCtx = scCanvas.getContext('2d'); 2475const scStatusEl = document.getElementById('sc-status'); 2476const scBtnCompass = document.getElementById('sc-btn-compass'); 2477document.getElementById('sc-sector-deg').textContent = SC_SECTOR_HALF; 2478 2479function scToRad(d) { return d * Math.PI / 180; } 2480function scNormAngle(a) { a = a % 360; return a < 0 ? a + 360 : a; } 2481function scNormDiff(a) { return ((a + 180) % 360 + 360) % 360 - 180; } 2482 2483function scBearing(lat1, lng1, lat2, lng2) { 2484 const Ï1 = scToRad(lat1), Ï2 = scToRad(lat2), Îλ = scToRad(lng2 - lng1); 2485 const y = Math.sin(Îλ) * Math.cos(Ï2); 2486 const x = Math.cos(Ï1) * Math.sin(Ï2) - Math.sin(Ï1) * Math.cos(Ï2) * Math.cos(Îλ); 2487 return scNormAngle(Math.atan2(y, x) * 180 / Math.PI); 2488} 2489function scDistanceKm(lat1, lng1, lat2, lng2) { 2490 const R = 6371, Ï1 = scToRad(lat1), Ï2 = scToRad(lat2), ÎÏ = scToRad(lat2 - lat1), Îλ = scToRad(lng2 - lng1); 2491 const a = Math.sin(ÎÏ/2)**2 + Math.cos(Ï1) * Math.cos(Ï2) * Math.sin(Îλ/2)**2; 2492 return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 2493} 2494 2495function startScannerTracking() { 2496 if (scWatchId !== null) return; // laeuft schon 2497 if (!navigator.geolocation) { scStatusEl.textContent = 'GPS auf diesem Gerät nicht verfügbar.'; scStatusEl.className = 'warn'; return; } 2498 scWatchId = navigator.geolocation.watchPosition(pos => { 2499 scUserLat = pos.coords.latitude; 2500 scUserLng = pos.coords.longitude; 2501 const spd = pos.coords.speed; 2502 if (pos.coords.heading !== null && !isNaN(pos.coords.heading) && (spd === null || spd >= SC_MOVING_MIN_SPEED)) { 2503 scHeading = pos.coords.heading; 2504 scHeadingSource = 'gps'; 2505 } 2506 scMaybeRefetch(); 2507 }, err => { 2508 scStatusEl.textContent = 'Standort nicht verfügbar: ' + err.message; 2509 scStatusEl.className = 'warn'; 2510 }, { enableHighAccuracy: true, maximumAge: 2000, timeout: 15000 }); 2511 2512 if (!scDrawing) { scDrawing = true; scFitCanvas(); requestAnimationFrame(scDrawRadar); } 2513 if (!scListInterval) scListInterval = setInterval(scRenderSectorList, 1000); 2514 if (!scStatusInterval) scStatusInterval = setInterval(scUpdateStatus, 1000); 2515 scUpdateCompassBtnVisibility();
2516 // 28.07.26 User-Wunsch: Querformat verhindern. Bonus fuer installierte Android-PWAs (echter 2517 // OS-Lock) - schlaegt ueberall sonst (u.a. immer auf iOS/Safari) einfach fehl, das ist der 2518 // Normalfall, kein Fehlerzustand. Die zuverlaessige Loesung ist der CSS-Dreh-Hinweis oben. 2519 try { screen.orientation?.lock?.('portrait').catch(() => {}); } catch (e) {} 2520} 2521function stopScannerTracking() { 2522 if (scWatchId !== null) { navigator.geolocation.clearWatch(scWatchId); scWatchId = null; } 2523 scDrawing = false; 2524 if (scListInterval) { clearInterval(scListInterval); scListInterval = null; } 2525 if (scStatusInterval) { clearInterval(scStatusInterval); scStatusInterval = null; } 2526 try { screen.orientation?.unlock?.(); } catch (e) {} 2527} 2528 2529function scEnableCompass() { 2530 const done = () => { 2531 window.addEventListener('deviceorientationabsolute', scOnOrientation, true); 2532 window.addEventListener('deviceorientation', scOnOrientation, true); 2533 scBtnCompass.style.display = 'none'; 2534 }; 2535 if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') { 2536 DeviceOrientationEvent.requestPermission().then(state => { 2537 if (state === 'granted') done(); 2538 else showToast('Kompass-Zugriff abgelehnt.'); 2539 }).catch(() => showToast('Kompass nicht verfügbar.')); 2540 } else { 2541 done(); 2542 } 2543} 2544scBtnCompass.addEventListener('click', scEnableCompass); 2545function scOnOrientation(e) { 2546 let h = null; 2547 if (typeof e.webkitCompassHeading === 'number') h = e.webkitCompassHeading; 2548 else if (e.absolute && e.alpha !== null) h = scNormAngle(360 - e.alpha); 2549 if (h !== null) { scHeading = h; scHeadingSource = 'compass'; } 2550} 2551function scUpdateCompassBtnVisibility() { 2552 scBtnCompass.style.display = (scHeadingSource !== 'gps' && scHeadingSource !== 'compass') ? 'inline-block' : 'none'; 2553} 2554 2555async function scMaybeRefetch() { 2556 if (scUserLat === null) return; 2557 const moved = scLastFetchLat === null || scDistanceKm(scLastFetchLat, scLastFetchLng, scUserLat, scUserLng) > 0.3; 2558 if (!moved) return; 2559 scLastFetchLat = scUserLat; scLastFetchLng = scUserLng; 2560 try { 2561 const r = await fetch(`api/nearby.php?lat=${scUserLat}&lng=${scUserLng}&mode=${currentMode}&r=${scRangeKm}`); 2562 const data = await r.json(); 2563 if (data.ok) scStations = data.stations; 2564 } catch (e) { /* stiller Retry beim naechsten watchPosition-Tick */ } 2565} 2566 2567document.querySelectorAll('[data-sc-range]').forEach(b => b.addEventListener('click', () => { 2568 document.querySelectorAll('[data-sc-range]').forEach(x => x.classList.toggle('active', x === b)); 2569 scRangeKm = parseFloat(b.dataset.scRange); 2570 scLastFetchLat = null; scMaybeRefetch(); 2571})); 2572 2573function scStationsInSector() { 2574 if (scUserLat === null || scHeading === null) return []; 2575 return scStations 2576 .filter(s => s.lat && s.lng && stationPrice(s)) 2577 .map(s => { 2578 const dist = scDistanceKm(scUserLat, scUserLng, s.lat, s.lng); 2579 const brg = scBearing(scUserLat, scUserLng, s.lat, s.lng); 2580 const rel = scNormDiff(brg - scHeading); 2581 return { s, dist, rel }; 2582 }) 2583 .filter(x => Math.abs(x.rel) <= SC_SECTOR_HALF && x.dist <= scRangeKm) 2584 .sort((a, b) => a.dist - b.dist); 2585} 2586 2587function scFitCanvas() { 2588 const wrap = document.getElementById('sc-radar-wrap'); 2589 // 28.07.26 Bugfix: im Querformat blendet CSS #sc-radar-wrap per display:none aus (siehe 2590 // Dreh-Hinweis weiter oben) - clientWidth/-Height sind dann 0, die Berechnung unten wuerde 2591 // negative Werte liefern. scCtx.arc() wirft bei negativem Radius eine Exception, die - 2592 // unabgefangen im rAF-Callback - die gesamte Zeichenschleife fuer den Rest der Sitzung 2593 // stumm sterben liess (kein sichtbarer Fehler, das Radar blieb einfach leer). Bei 2594 // verstecktem/zu kleinem Container einfach nichts tun statt kaputte Masse zu setzen. 2595 if (wrap.clientWidth <= 0) return; 2596 // 14.08.2026 User-Wunsch ("passe den Scanner so an, dass er möglichst viel vom freien Bereich 2597 // nutzt"): die feste 420px-Obergrenze nahm auf groÃen Displays/im Querformat unnötig Platz weg 2598 // (Sektor blieb winzig trotz viel Luft drumherum) - Canvas füllt jetzt den kompletten 2599 // verfügbaren Wrapper aus (nur kleiner Rand für Range-Beschriftung/Touch-Ränder), keine 2600 // künstliche Maximalbreite mehr. scDrawRadarFrame() leitet den Sektor-Radius R ohnehin
2600schon 2601 // dynamisch aus w/h ab, skaliert also automatisch mit. 2602 // 14.08.2026 Nachbesserung (User-Report mit Screenshot: "der Scanner beim Iphone viel zu weit 2603 // unten") - scDrawRadarFrame() begrenzt den Sektor-Radius R durch die BREITE (R = min(h-16, 2604 // w/2-6), Apex sitzt am unteren Rand). Bei schmalen Hochkant-Handys ist w/2 fast immer kleiner 2605 // als die vorher starre 58vh-Wrapper-Hoehe - der Faecher blieb klein UND haengte am unteren Rand 2606 // eines viel zu hohen, oben leeren Containers. Wrapper-Hoehe deshalb jetzt an die tatsaechlich 2607 // nutzbare Breite gekoppelt (Radius + Rand fuer Range-Beschriftung oben/Touch unten) statt an 2608 // eine feste vh-Vorgabe - die CSS-Werte (58vh/max 480px) bleiben nur noch als Obergrenze fuer 2609 // breite/Tablet-aehnliche Geraete erhalten. 2610 const wForRatio = wrap.clientWidth - 12; 2611 const idealH = Math.round(wForRatio / 2 + 46); 2612 wrap.style.height = Math.min(idealH, window.innerHeight * 0.58) + 'px'; 2613 if (wrap.clientHeight <= 0) return; 2614 const w = wrap.clientWidth - 12; 2615 const h = wrap.clientHeight - 8; 2616 const dpr = window.devicePixelRatio || 1; 2617 scCanvas.style.width = w + 'px'; 2618 scCanvas.style.height = h + 'px'; 2619 scCanvas.width = w * dpr; 2620 scCanvas.height = h * dpr; 2621 scCtx.setTransform(dpr, 0, 0, dpr, 0, 0); 2622 scCanvas._cssW = w; scCanvas._cssH = h; 2623} 2624window.addEventListener('resize', () => { if (activeView === 'scanner') scFitCanvas(); }); 2625 2626let scBlipHitboxes = []; 2627function scDrawRadar() { 2628 if (!scDrawing) return; // View verlassen -> Loop stoppen (Akku) 2629 // 28.07.26 Bugfix: ein einzelner Fehler beim Zeichnen (z.B. durch eine kurzzeitig zu kleine 2630 // Canvas-Groesse) darf die rAF-Kette nicht abreissen lassen, sonst bleibt das Radar fuer den 2631 // Rest der Sitzung leer, ohne dass es im UI erkennbar waere - siehe scFitCanvas()-Kommentar. 2632 try { 2633 scDrawRadarFrame(); 2634 } catch (e) { /* naechster Frame versucht es erneut */ } 2635 requestAnimationFrame(scDrawRadar); 2636} 2637function scDrawRadarFrame() { 2638 const w = scCanvas._cssW, h = scCanvas._cssH; 2639 if (!w || !h || w <= 0 || h <= 0) return; 2640 const ox = w / 2, oy = h - 6; 2641 const R = Math.max(1, Math.min(h - 16, w / 2 - 6)); 2642 // 14.08.2026 User-Wunsch ("Linien noch etwas dicker und die Punkte dann auch, gerne 2643 // proportional zum Darstellungsbereich wachsend"): S ist der Skalierungsfaktor gegenüber dem 2644 // bisherigen typischen Radius (~150px bei der alten 420px-Deckelung) - alle Linienstärken/ 2645 // Punktgrössen unten werden mit S multipliziert, wachsen also automatisch mit, wenn der 2646 // Scanner (siehe scFitCanvas()) mehr Platz bekommt, bleiben aber auf kleinen Screens nicht zu 2647 // duenn (untere Grenze 0.85) bzw. auf sehr grossen nicht grotesk dick (obere Grenze 2.2). 2648 const S = Math.max(0.85, Math.min(R / 150, 2.2)); 2649 scCtx.clearRect(0, 0, w, h); 2650 const haveDir = scHeading !== null && scUserLat !== null; 2651 2652 scCtx.save(); 2653 scCtx.beginPath(); 2654 scCtx.moveTo(ox, oy); 2655 scCtx.arc(ox, oy, R, scToRad(-90 - SC_SECTOR_HALF), scToRad(-90 + SC_SECTOR_HALF)); 2656 scCtx.closePath(); 2657 const grad = scCtx.createRadialGradient(ox, oy, 0, ox, oy, R); 2658 grad.addColorStop(0, 'rgba(34,224,122,.16)'); 2659 grad.addColorStop(1, 'rgba(34,224,122,.02)'); 2660 scCtx.fillStyle = haveDir ? grad : 'rgba(120,120,120,.06)'; 2661 scCtx.fill(); 2662 scCtx.strokeStyle = 'rgba(34,224,122,.55)'; 2663 scCtx.lineWidth = 4.5 * S; 2664 scCtx.stroke(); 2665 scCtx.restore(); 2666 2667 [1/3, 2/3, 1].forEach(f => { 2668 scCtx.beginPath(); 2669 scCtx.arc(ox, oy, R * f, scToRad(-90 - SC_SECTOR_HALF), scToRad(-90 + SC_SECTOR_HALF)); 2670 scCtx.strokeStyle = 'rgba(34,224,122,.22)'; 2671 scCtx.lineWidth = (f === 1 ? 3 : 1.6) * S; 2672 scCtx.stroke(); 2673 scCtx.fillStyle = 'rgba(180,220,200,.55)'; 2674 scCtx.font = Math.round(9 * S) + 'px -apple-system, sans-serif'; 2675 scCtx.textAlign = 'center'; 2676 scCtx.fillText((scRangeKm * f).toFixed(0) + ' km', ox, oy - R * f - 4 * S); 2677 }); 2678 2679 [-SC_SECTOR_HALF, SC_SECTOR_HALF].forEach(a => { 2680 const rad = scToRad(a - 90); 2681 scCtx.beginPath(); 2682 scCtx.moveTo(ox, oy); 2683 scCtx.lineTo(ox + R * Math.cos(rad), oy + R * Math.sin(rad)); 2684 scCtx.strokeStyle = 'rgba(34,224,122,.4)'; 2685 scCtx.lineWidth = 3 * S; 2686 scCtx.stroke(); 2687 }); 2688 2689 if (haveDir) { 2690 scSweepAngle += scSweepDir * 1.4; 2691 if (scSweepAngle > SC_SECTOR_HALF) { scSweepAngle = SC_SECTOR_HALF; scSweepDir = -1; } 2692 if (scSweepAngle < -SC_SECTOR_HALF) { scSweepAngle = -SC_SECTOR_HALF; scSweepDir = 1; } 2693 2694 const TRAIL_DEG = 22, TRAIL_STEPS = 16; 2695 for (let i = TRAIL_STEPS; i >= 1; i--) { 2696 const t = i / TRAIL_STEPS; 2697 const a = scSweepAngle - scSweepDir * TRAIL_DEG * t; 2698 if (a < -SC_SECTOR_HALF || a > SC_SECTOR_HALF) continue;
2699 const trad = scToRad(a - 90); 2700 scCtx.beginPath(); 2701 scCtx.moveTo(ox, oy); 2702 scCtx.lineTo(ox + R * Math.cos(trad), oy + R * Math.sin(trad)); 2703 scCtx.strokeStyle = `rgba(34,224,122,${(1 - t) * 0.45})`; 2704 scCtx.lineWidth = 3 * S; 2705 scCtx.stroke(); 2706 } 2707 2708 const rad = scToRad(scSweepAngle - 90); 2709 const trailGrad = scCtx.createLinearGradient(ox, oy, ox + R * Math.cos(rad), oy + R * Math.sin(rad)); 2710 trailGrad.addColorStop(0, 'rgba(34,224,122,0)'); 2711 trailGrad.addColorStop(1, 'rgba(34,224,122,.9)'); 2712 scCtx.beginPath(); 2713 scCtx.moveTo(ox, oy); 2714 scCtx.lineTo(ox + R * Math.cos(rad), oy + R * Math.sin(rad)); 2715 scCtx.strokeStyle = trailGrad; 2716 scCtx.lineWidth = 3 * S; 2717 scCtx.stroke(); 2718 } 2719 2720 scCtx.beginPath(); 2721 scCtx.arc(ox, oy, 4 * S, 0, Math.PI * 2); 2722 scCtx.fillStyle = '#eafff2'; 2723 scCtx.fill(); 2724 2725 scBlipHitboxes = []; 2726 if (haveDir) { 2727 const inSector = scStationsInSector(); 2728 const avg = inSector.length ? inSector.reduce((sum, x) => sum + stationPrice(x.s), 0) / inSector.length : null; 2729 inSector.forEach(x => { 2730 const r = Math.min(x.dist / scRangeKm, 1) * (R - 10) + 6; 2731 const rad = scToRad(x.rel - 90); 2732 const bx = ox + r * Math.cos(rad), by = oy + r * Math.sin(rad); 2733 const col = pinColor(stationPrice(x.s), avg); 2734 const isSel = scSelectedBlip === x.s.id; 2735 const blipR = (isSel ? 8 : 6) * S; 2736 scCtx.beginPath(); 2737 scCtx.arc(bx, by, blipR, 0, Math.PI * 2); 2738 scCtx.fillStyle = col; 2739 scCtx.fill(); 2740 if (isSel) { scCtx.strokeStyle = '#fff'; scCtx.lineWidth = 2.5 * S; scCtx.stroke(); } 2741 if (x.s.h24) { 2742 scCtx.beginPath(); 2743 scCtx.arc(bx, by, (isSel ? 12 : 10) * S, 0, Math.PI * 2); 2744 scCtx.strokeStyle = 'rgba(255,255,255,.35)'; 2745 scCtx.lineWidth = 1.5 * S; 2746 scCtx.stroke(); 2747 } 2748 // Trefferflaeche fuer Taps waechst mit dem Punkt mit, sonst wird das Antippen auf grossen 2749 // Displays (grosse Punkte) unverhaeltnismaessig schwer bzw. bleibt auf kleinen zu grosszuegig. 2750 scBlipHitboxes.push({ x: bx, y: by, r: Math.max(14, blipR * 2), entry: x }); 2751 }); 2752 } 2753} 2754 2755scCanvas.addEventListener('click', e => { 2756 const rect = scCanvas.getBoundingClientRect(); 2757 const mx = e.clientX - rect.left, my = e.clientY - rect.top; 2758 const hit = scBlipHitboxes.find(b => Math.hypot(b.x - mx, b.y - my) <= b.r); 2759 scShowBlipCard(hit ? hit.entry : null); 2760}); 2761 2762function scShowBlipCard(entry) { 2763 const card = document.getElementById('sc-blip-card'); 2764 if (!entry) { card.style.display = 'none'; scSelectedBlip = null; return; } 2765 scSelectedBlip = entry.s.id; 2766 const s = entry.s; 2767 const price = stationPrice(s); 2768 const oezText = s.h24 ? 'Durchgehend geöffnet (24h)' : (s.oez || ''); 2769 const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 2770 const isAndroid = /Android/.test(navigator.userAgent); 2771 const geoUrl = isIOS 2772 ? `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d` 2773 : isAndroid 2774 ? `https://www.google.com/maps/dir/?api=1&destination=${s.lat},${s.lng}` 2775 : `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d`; 2776 card.innerHTML = `<span class="price">${price ? price.toFixed(3) + ' ' + priceUnit(s) : 'â'}</span> 2777 <div class="name">${s.name}</div> 2778 <div class="meta">${s.strasse}, ${s.plz} ${s.ort} · ${entry.dist.toFixed(1)} km · ${entry.rel >= 0 ? entry.rel.toFixed(0)+'° rechts' : Math.abs(entry.rel).toFixed(0)+'° links'}</div> 2779 ${oezText ? `<div class="oez">ð ${oezText}</div>` : ''} 2780 <a href="${geoUrl}" class="nav-link"> 2781 <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> 2782 <polygon points="3 11 22 2 13 21 11 13 3 11"/> 2783 </svg> Navigieren 2784 </a>`; 2785 card.style.display = 'block'; 2786} 2787 2788function scRenderSectorList() { 2789 const rows = document.getElementById('sc-sector-rows'); 2790 const empty = document.getElementById('sc-sector-empty'); 2791 const list = scStationsInSector(); 2792 if (!list.length) { rows.innerHTML = ''; empty.style.display = 'block'; return; } 2793 empty.style.display = 'none'; 2794 const avg = list.reduce((sum, x) => sum + stationPrice(x.s), 0) / list.length; 2795 rows.innerHTML = list.slice(0, 12).map(x => { 2796 const price = stationPrice(x.s); 2797 return `<div class="sc-row" data-id="${x.s.id}"> 2798 <div class="sc-dot" style="background:${pinColor(price, avg)}"></div> 2799 <div class="sc-name">${x.s.name}</div> 2800 <div class="sc-dist">${x.dist.toFixed(1)} km</div> 2801 <div class="sc-price">${price ? price.toFixed(3)+' '+priceUnit(x.s) : 'â'}</div> 2802 </div>`; 2803 }).join(''); 2804 rows.querySelectorAll('.sc-row').forEach(row => { 2805 row.addEventListener('click', () => { 2806 const entry = list.find(x => String(x.s.id) === row.dataset.id); 2807 scShowBlipCard(entry); 2808 }); 2809 }); 2810} 2811 2812function scUpdateStatus() { 2813 scUpdateCompassBtnVisibility(); 2814 if (scUserLat === null) { scStatusEl.textContent = 'Warte auf Standortâ¦'; scStatusEl.className = ''; return; } 2815 if (scHeading === null) { 2816 scStatusEl.textContent = 'â³ Bewege dich, damit die Fahrtrichtung erkannt wird â oder Kompass aktivieren.'; 2817 scStatusEl.className = 'warn'; 2818 return; 2819 } 2820 const src = scHeadingSource === 'gps' ? 'GPS-Kurs' : 'Kompass'; 2821 scStatusEl.textContent = `ð§ ${src}: ${Math.round(scHeading)}°`; 2822 scStatusEl.className = 'ok'; 2823} 2824 2825// ââ Ãbersichtskarte ââ 2826// 11.08.2026 User-Idee: optionaler "GroÃ"-Schalter fuer die Karte - bessere Lesbarkeit bei 2827// schlechten Sichtverhaeltnissen, dafuer weniger Tankstellen gleichzeitig im Bild. Zwei Effekte: 2828// (1) groessere Marker+Text per CSS-Regel auf ".mk-annotation-container" (siehe Style-Block 2829// oben, ".map-large-active") - erster Versuch war ein JS-Hack per ann.element.style.transform, 2830// der aber nach addAnnotations() nie zuverlaessig griff (User-Feedback "sehe kaum einen 2831// Unterschied"), daher auf reine CSS-Klasse umgestellt. (2) engerer Kartenausschnitt: 2832// showItems() bekommt auf User-Nachfrage ("dann auch den Kartenausschnitt verkleinern?") nur 2833// die naechsten paar Stationen statt aller - wer weiterpannt, findet die uebrigen trotzdem 2834// noch (sie werden nicht aus der Liste entfernt). 2835const MAP_LARGE_KEY = 'gasdb_map_large'; 2836const MAP_LARGE_MAX_ITEMS = 6; 2837let mapLargeMode = localStorage.getItem(MAP_LARGE_KEY) === '1'; 2838 2839function initOverviewMap() { 2840 if (!currentStations.length) return; 2841 setTimeout(() => { 2842 if (overviewMap) { overviewMap.destroy(); overviewMap = null; } 2843 overviewMap = new mapkit.Map('full-map', { 2844 showsCompass: mapkit.FeatureVisibility.Hidden, 2845 showsUserLocation: true, 2846 colorScheme: pwaMkColorScheme(), 2847 }); 2848 2849 // 11.08.2026 (2. Korrektur): MapKit rendert normale MarkerAnnotation-Pins auf einem 2850 // WebGL/Canvas-Layer ("syrup-canvas") - im DOM existiert dafuer nur EIN unsichtbares 2851 // ".mk-annotation-container"-Element (Hit-Testing), egal wie viele Pins sichtbar sind. 2852 // CSS transform:scale() darauf aendert deshalb rein gar nichts am tatsaechlich gezeichneten 2853 // Bild (User-Feedback "keinerlei Aenderung" bestaetigt das). Fuer echte, garantiert 2854 // wirksame Groessenaenderung im GroÃ-Modus daher eigene DOM-basierte mapkit.Annotation 2855 // (Factory-Funktion liefert echtes <div>, MapKit positioniert es nur per CSS, zeichnet 2856 // nicht selbst) statt MarkerAnnotation - dort ist die Groesse direkt im Markup, kein Hack. 2857 const anns = currentStations 2858 .filter(s => s.lat && s.lng) 2859 .map(s => { 2860 if (mapLargeMode) { 2861 // 12.08.2026 Bugfix (User-Report: "beide Preise angezeigt, obwohl schon LPG 2862 // ausgewaehlt ist") - zeigte bisher immer LPG+CNG zusammen, auch wenn die Station 2863 // beide Preise hat, aber nur eine Gasart gerade ausgewaehlt (currentMode) ist. 2864 const priceTxt = currentMode === 'cng' 2865 ? (s.cng ? `${s.cng.toFixed(3)}${priceUnit(s)}` : '') 2866 : (s.lpg ? `${s.lpg.toFixed(3)}${priceUnit(s)}` : ''); 2867 const color = pinColor(stationPrice(s), currentAvgPrice); 2868 const factory = () => { 2869 const div = document.createElement('div'); 2870 div.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;'; 2871 div.innerHTML = ` 2872 <div style="background:${color};color:#fff;border-radius:50%;width:54px;height:54px; 2873 display:flex;align-items:center;justify-content:center;font-size:1.7rem; 2874 box-shadow:0 3px 10px rgba(0,0,0,.45);border:3px solid #fff;">${s.h24 ? '24' : 'â½'}</div> 2875 <div style="background:#fff;color:#222;border-radius:6px;padding:3px 9px;margin-top:3px; 2876 font-size:14px;font-weight:700;line-height:1.3;text-align:center;white-space:nowrap; 2877 box-shadow:0 2px 8px rgba(0,0,0,.35);">${esc(s.name)}<br>${esc(priceTxt)}</div>`; 2878 div.addEventListener('click', () => showDetail(s)); 2879 return div; 2880 }; 2881 const ann = new mapkit.Annotation(new mapkit.Coordinate(s.lat, s.lng), factory, { 2882 anchorOffset: new DOMPoint(0, -20), 2883 calloutEnabled: false, 2884 }); 2885 return ann; 2886 } 2887 const ann = new mapkit.MarkerAnnotation( 2888 new mapkit.Coordinate(s.lat, s.lng), 2889 { 2890 title: s.name + (s.h24 ? ' (24h)' : ''), 2891 // 18.07.26: Gas-Art-Praefix vor dem Preis entfernt (User-Wunsch: 2892 // "die Gas-Art vor dem Preis ist ueberfluessig") - nur noch der 2893 // Preis, ohne "LPG"/"CNG"-Label. 2894 // 12.08.2026 Bugfix (User-Report: "beide Preise angezeigt, obwohl schon LPG 2895 // ausgewaehlt ist") - siehe gleicher Fix oben im GroÃ-Modus-Zweig. 2896 subtitle: currentMode === 'cng' 2897 ? (s.cng ? `${s.cng.toFixed(3)}${priceUnit(s)}` : '') 2898 : (s.lpg ? `${s.lpg.toFixed(3)}${priceUnit(s)}` : ''), 2899 // 18.07.26: Titel+Preis (subtitle) staendig sichtbar statt nur bei 2900 // Auswahl (User-Wunsch: "unter jedem Namen der Tankstelle den 2901 // aktuellen Preis anzeigen") - subtitle enthaelt den Preis. 2902 titleVisibility: mapkit.FeatureVisibility.Visible, 2903 subtitleVisibility: mapkit.FeatureVisibility.Visible, 2904 color: pinColor(stationPrice(s), currentAvgPrice), glyphText: s.h24 ? '24' : 'â½', 2905 callout: { 2906 calloutContentForAnnotation(ann) { 2907 const st = currentStations.find(x => x.lat == ann.coordinate.latitude && x.lng == ann.coordinate.longitude); 2908 const div = document.createElement('div'); 2909 div.style.cssText = 'min-width:160px;font-size:13px;line-height:1.6'; 2910 div.innerHTML = `<b>${esc(st.name)}</b><br>${esc(st.plz)} ${esc(st.ort)}<br> 2911 <a href="#" onclick="event.preventDefault();showDetail(window._st_${st.id})" 2912 style="color:#1D4CA3;font-weight:700">Details â</a>`; 2913 window[`_st_${st.id}`] = st; 2914 return div; 2915 } 2916 } 2917 } 2918 ); 2919 return ann; 2920 }); 2921 overviewMap.addAnnotations(anns); 2922 // 15.08.2026 User-Wunsch ("im Auto-Modus sollte der aktuelle Standort immer in der Mitte 2923 // sein") - showItems() zoomt/verschiebt die Karte bisher immer nur so, dass ALLE 2924 // Stations-Pins sichtbar sind (Bounding-Box ueber die Annotations) - liegen die Treffer 2925 // z.B. ueberwiegend noerdlich vom eigenen Standort, landet der eigene Positionspunkt am 2926 // Kartenrand statt mittig. Im Auto-Modus (waehrend der Fahrt) ist eine feste Zentrierung 2927 // auf die zuletzt gesuchte Position (= aktueller Standort, siehe fetchNearby()) wichtiger 2928 // als alle Treffer vollstaendig einzurahmen - Radius bestimmt den Zoom. Bei manueller Suche 2929 // (PLZ/Ort/Adresse) bleibt das bisherige "alle Treffer einrahmen"-Verhalten wie gewohnt. 2930 const fitAnns = mapLargeMode ? anns.slice(0, MAP_LARGE_MAX_ITEMS) : anns; 2931 if (mapAutoMode && lastSearchLat !== null && lastSearchLng !== null) { 2932 const r = parseFloat(document.getElementById('gps-radius')?.value) || 5; 2933 const spanDeg = Math.max(0.02, (r / 111) * 2.4); // grober kmâGrad-Faktor + Rand 2934 overviewMap.region = new mapkit.CoordinateRegion( 2935 new mapkit.Coordinate(lastSearchLat, lastSearchLng), 2936 new mapkit.CoordinateSpan(spanDeg, spanDeg) 2937 ); 2938 } else { 2939 overviewMap.showItems(fitAnns.length ? fitAnns : anns, { animate: false, padding: new mapkit.Padding(40,40,40,40) }); 2940 } 2941 }, 80); 2942} 2943 2944function updateMapLargeToggleUI() { 2945 const btn = document.getElementById('map-large-toggle'); 2946 if (btn) btn.classList.toggle('active', mapLargeMode); 2947} 2948document.getElementById('map-large-toggle')?.addEventListener('click', () => { 2949 mapLargeMode = !mapLargeMode; 2950 localStorage.setItem(MAP_LARGE_KEY, mapLargeMode ? '1' : '0'); 2951 updateMapLargeToggleUI(); 2952 // 12.08.2026 User-Wunsch: beim Einschalten des GroÃ-Modus automatisch auf 2,5 km stellen 2953 // (passt zu "weniger Tankstellen, besser lesbar") - normaler 5 km-Default bleibt sonst 2954 // unveraendert, beim Ausschalten wird der Radius nicht zurueckgesetzt. 2955 const radiusSel = document.getElementById('gps-radius'); 2956 if (mapLargeMode && radiusSel) { 2957 radiusSel.value = '2.5'; 2958 if (lastSearchLat !== null) { 2959 fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel).then(() => showView('map')); 2960 return; 2961 } 2962 updateMapRadiusUI(2.5); 2963 } 2964 if (activeView === 'map') initOverviewMap(); 2965}); 2966updateMapLargeToggleUI(); 2967
2968// 11.08.2026 User-Idee: "Auto"-Modus fuer die Karte - waehrend der Fahrt Standort+Karte alle 2969// 5 Minuten automatisch aktualisieren, ohne manuell auf GPS tippen zu muessen. Timer laeuft 2970// NUR waehrend die Kartenansicht aktiv ist (siehe showView()) - spart Akku/GPS ausserhalb. 2971const MAP_AUTO_KEY = 'gasdb_map_auto'; 2972const MAP_AUTO_INTERVAL_MS = 5 * 60 * 1000; 2973let mapAutoMode = localStorage.getItem(MAP_AUTO_KEY) === '1'; 2974let mapAutoTimer = null; 2975 2976function updateMapAutoToggleUI() { 2977 const btn = document.getElementById('map-auto-toggle'); 2978 if (btn) btn.classList.toggle('active', mapAutoMode); 2979} 2980function mapAutoRefresh() { 2981 if (activeView !== 'map' || !navigator.geolocation) return; 2982 navigator.geolocation.getCurrentPosition( 2983 pos => { 2984 // 11.08.2026 Bugfix (User-Report: "tippt man auf das Auto-Icon, wechselt er zur Liste") - 2985 // fetchNearby()/renderList() springen intern IMMER zur Listenansicht (siehe stepRadius() 2986 // oben, gleiches Problem+gleiche Loesung). Der frueher Return oben garantiert, dass wir 2987 // hier nur landen, wenn die Karte aktiv WAR - showView('map') danach holt sie zurueck 2988 // (ruft auch initOverviewMap() bereits selbst auf). 2989 fetchNearby(pos.coords.latitude, pos.coords.longitude).then(() => showView('map')); 2990 }, 2991 () => {}, // Fehler beim automatischen Refresh bewusst still - kein Toast waehrend der Fahrt 2992 { timeout: 10000, enableHighAccuracy: true } 2993 ); 2994} 2995function startMapAutoTracking() { 2996 stopMapAutoTracking(); 2997 if (!mapAutoMode || activeView !== 'map') return; 2998 mapAutoTimer = setInterval(mapAutoRefresh, MAP_AUTO_INTERVAL_MS); 2999} 3000function stopMapAutoTracking() { 3001 if (mapAutoTimer) { clearInterval(mapAutoTimer); mapAutoTimer = null; } 3002} 3003document.getElementById('map-auto-toggle')?.addEventListener('click', () => { 3004 mapAutoMode = !mapAutoMode; 3005 localStorage.setItem(MAP_AUTO_KEY, mapAutoMode ? '1' : '0'); 3006 updateMapAutoToggleUI(); 3007 showMapToast(mapAutoMode ? 'ð Auto-Folgen an' : 'Auto-Folgen aus'); // 11.08.2026 User-Wunsch 3008 // 15.08.2026 User-Wunsch ("bei 'Auto an' sollte er immer auf den 2,5km Radius springen") - 3009 // gleiches Muster wie beim GroÃ-Modus-Toggle oben: passt zu "wenige, gut lesbare Treffer 3010 // waehrend der Fahrt". Radius wird beim Ausschalten bewusst NICHT zurueckgesetzt (wie beim 3011 // GroÃ-Modus auch). 3012 const radiusSel = document.getElementById('gps-radius'); 3013 if (mapAutoMode && radiusSel) { 3014 radiusSel.value = '2.5'; 3015 updateMapRadiusUI(2.5); 3016 } 3017 if (mapAutoMode && activeView === 'map') { mapAutoRefresh(); startMapAutoTracking(); } 3018 else stopMapAutoTracking(); 3019}); 3020updateMapAutoToggleUI(); 3021 3022// ââ Langer Fingerdruck auf die Karte â neue Suche an dieser Position ââ 3023// mapkit.Map kennt kein eigenes Long-Press-Event (nur select/region-change/...), daher 3024// per DOM-Touch-Timer auf den Karten-Container selbst gebaut (gleicher Grundansatz wie 3025// der Klick-Handler in new_map.php: convertPointOnPageToCoordinate() für PixelâKoordinate). 3026(function () { 3027 const mapEl = document.getElementById('full-map'); 3028 if (!mapEl) return; 3029 let pressTimer = null; 3030 let startX = 0, startY = 0; 3031 const MOVE_TOLERANCE = 10; // px, sonst zaehlt es als Kartenziehen statt Long-Press 3032 3033 mapEl.addEventListener('touchstart', (e) => { 3034 if (!overviewMap || e.touches.length !== 1) return; 3035 const t = e.touches[0]; 3036 startX = t.pageX; startY = t.pageY; 3037 pressTimer = setTimeout(() => { 3038 pressTimer = null; 3039 const point = new DOMPoint(startX, startY); 3040 const c = overviewMap.convertPointOnPageToCoordinate(point); 3041 if (!c) return; 3042 if (navigator.vibrate) navigator.vibrate(30); 3043 showToast('Suche an dieser Position â¦'); 3044 // 26.08.2026 Bugfix (User-Report: "Longpress auf die Karte soll danach auch wieder die 3045 // Karte anzeigen und nicht die Liste") - fetchNearby()/renderList() springen intern IMMER 3046 // zur Listenansicht (gleiches bereits bekanntes Problem wie beim "Auto"-Icon, siehe 3047 // mapAutoRefresh() oben, 11.08.2026). showView('map') danach holt die Kartenansicht zurueck. 3048 fetchNearby(c.latitude, c.longitude, 'Gewählte Position').then(() => showView('map')); 3049 }, 550); 3050 }, { passive: true }); 3051 3052 mapEl.addEventListener('touchmove', (e) => { 3053 if (!pressTimer) return; 3054 const t = e.touches[0]; 3055 if (Math.abs(t.pageX - startX) > MOVE_TOLERANCE || Math.abs(t.pageY - startY) > MOVE_TOLERANCE) { 3056 clearTimeout(pressTimer); pressTimer = null; 3057 } 3058 }, { passive: true }); 3059 3060 ['touchend', 'touchcancel'].forEach(ev => mapEl.addEventListener(ev, () => { 3061 if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } 3062 }, { passive: true })); 3063})(); 3064 3065// ââ API ââ 3066async function fetchNearby(lat, lng, label) { 3067 lastSearchLat = lat; lastSearchLng = lng; lastSearchLabel = label; 3068 showLoader(true); 3069 try { 3070 // 18.07.26: Radius je Kraftstoffart als Vorbelegung (LPG-Stationen liegen 3071 // deutlich dichter als CNG), per Selektor neben "Meinen Standort nutzen" 3072 // oder den +/- Stufen-Buttons auf der Karte manuell ueberschreibbar (User-Wunsch). 3073 const radiusSel = document.getElementById('gps-radius'); 3074 const r = radiusSel ? parseFloat(radiusSel.value) : (currentMode === 'cng' ? 20 : 5); 3075 updateMapRadiusUI(r); 3076 const url = `api/nearby.php?lat=${lat}&lng=${lng}&mode=${currentMode}&r=${r}${searchFilterParam()}`; 3077 const res = await fetch(url); 3078 const data = await res.json(); 3079 if (!data.ok) throw new Error(data.error || 'Fehler'); 3080 // 18.07.26: Suchradius statt Kraftstoffart im Listentitel (User
3080-Wunsch: 3081 // "In der Nähe (7,5km)" - LPG/CNG steht bereits im Mode-Tab, muss hier 3082 // nicht doppelt stehen). 3083 const rFmt = Number.isInteger(r) ? r : String(r).replace('.', ','); 3084 const title = label ? `${label} (${rFmt}km)` : `In der Nähe (${rFmt}km)`; 3085 renderList(data.stations, title); 3086 saveOfflineCache(data.stations, title); 3087 if (!data.stations.length) showToast(`Keine Tankstellen in ${r} km gefunden.`); 3088 } catch(e) { 3089 if (!(navigator.onLine === false && tryOfflineFallback())) { 3090 showToast('Fehler: ' + e.message); 3091 } 3092 } finally { 3093 showLoader(false); 3094 } 3095} 3096 3097const _pwaCountryCenters = { 3098 de: [51.1657, 10.4515], at: [47.5162, 14.5501], ch: [46.8182, 8.2275], 3099 nl: [52.1326, 5.2913], be: [50.5039, 4.4699], fr: [46.2276, 2.2137], 3100 pl: [51.9194, 19.1451], cz: [49.8175, 15.4730], lu: [49.8153, 6.1296], 3101}; 3102async function fetchSearchByAddress(q) { 3103 return new Promise(resolve => { 3104 // 10.07.26: "XX-Ort"-Praefix (z.B. "NL-Soest") zwingt die Suche auf ein einzelnes Land, 3105 // sonst Deutschland als Schwerpunkt unter allen erlaubten Laendern. 3106 const m = q.match(/^([A-Za-z]{2})-(.+)$/); 3107 const forcedCountry = m && _pwaCountryCenters[m[1].toLowerCase()] ? m[1].toUpperCase() : null; 3108 if (forcedCountry) q = m[2].trim(); 3109 const center = _pwaCountryCenters[(forcedCountry || 'de').toLowerCase()]; 3110 const geocoder = new mapkit.Geocoder({ language: 'de-DE' }); 3111 geocoder.lookup(q, async (err, result) => { 3112 if (err || !result.results || !result.results.length) { 3113 showToast('Adresse nicht gefunden â bitte PLZ oder Ort eingeben.'); 3114 resolve(); return; 3115 } 3116 const { latitude, longitude } = result.results[0].coordinate; 3117 const place = result.results[0].formattedAddress || q; 3118 await fetchNearby(latitude, longitude, place); 3119 resolve(); 3120 }, {
3121 limitToCountries: forcedCountry || 'DE,AT,CH,LU,BE,NL,FR,PL,CZ', 3122 region: new mapkit.CoordinateRegion( 3123 new mapkit.Coordinate(center[0], center[1]), 3124 new mapkit.CoordinateSpan(forcedCountry ? 3 : 6, forcedCountry ? 4 : 8) 3125 ), 3126 }); 3127 }); 3128} 3129 3130async function fetchSearch(q) { 3131 // 10.07.26: "!"-Praefix erzwingt die Geocoder-Suche direkt (z.B. "!Soest" oder 3132 // "!NL-Soest"), statt zuerst die lokale PLZ/Ort/Kennzeichen-Suche (api/search.php) 3133 // zu versuchen. Noetig, weil manche Orte (Land-Praefix) in der Tankstellen-DB nicht 3134 // ueber PLZ/Ort-Treffer aufgeloest werden koennen bzw. Mehrdeutigkeiten haben. 3135 if (q.startsWith('!')) { 3136 await fetchSearchByAddress(q.slice(1).trim()); 3137 return; 3138 } 3139 // Adress-Erkennung: Komma ODER StraÃen-Keywords â direkt geocodieren 3140 const looksLikeAddress = q.includes(',') || 3141 /\b(str\.|straÃe|strasse|weg|allee|gasse|platz|ring|chaussee|damm|ufer|steig|promenade)\b/i.test(q); 3142 if (looksLikeAddress) { 3143 await fetchSearchByAddress(q); 3144 return; 3145 } 3146 showLoader(true); 3147 try { 3148 const url = `api/search.php?q=${encodeURIComponent(q)}&mode=${currentMode}&r=25${searchFilterParam()}`; 3149 const res = await fetch(url); 3150 const data = await res.json(); 3151 // 10.07.26: Bugfix - "ok:false" (z.B. "Ort nicht gefunden" bei "NL-Soest") loeste 3152 // vorher sofort eine Exception aus und sprang NIE zum Geocoding-Fallback unten. 3153 // Jetzt: jedes Nicht-Treffer-Ergebnis (ok:false ODER leere stations) faellt gleich 3154 // auf die Adress-/Geocoder-Suche zurueck, echte Fehler (Netzwerk/JSON) landen weiter im catch. 3155 if (data.ok && data.stations && data.stations.length) { 3156 const titleSuffix = data.bezirk ? ` (${data.bezirk})` : ''; 3157 const title = `${q}${titleSuffix} â ${currentMode.toUpperCase()}`; 3158 renderList(data.stations, title); 3159 saveOfflineCache(data.stations, title); 3160 } else { 3161 showToast('Kein PLZ/Ort gefunden â versuche Adress-Sucheâ¦'); 3162 await fetchSearchByAddress(q); 3163 } 3164 } catch(e) { 3165 if (!(navigator.onLine === false && tryOfflineFallback())) { 3166 showToast('Fehler: ' + e.message); 3167 } 3168 } finally { 3169 showLoader(false); 3170 } 3171} 3172 3173// ââ GPS ââ 3174document.getElementById('btn-gps').addEventListener('click', () => { 3175 if (!navigator.geolocation) { showToast('GPS nicht verfügbar'); return; } 3176 showLoader(true); 3177 navigator.geolocation.getCurrentPosition( 3178 pos => { 3179 userLat = pos.coords.latitude; 3180 userLng = pos.coords.longitude; 3181 showLoader(false); 3182 fetchNearby(userLat, userLng); 3183 }, 3184 err => { 3185 showLoader(false); 3186 showToast('Standort nicht verfügbar: ' + err.message); 3187 }, 3188 { timeout: 10000, enableHighAccuracy: true } 3189 ); 3190}); 3191 3192// 05.09.2026 (User-Idee: "vereinfachte PWA-URL um sofort auf die Detail-Ansicht der nächsten 3193// Tankstelle zu gelangen [...] fahre ich an einer Tankstelle vorbei und will nur kurz den 3194// angezeigten Preis aktualisieren") - per /mobile?q erreichbar (siehe Boot-Code unten): holt den 3195// Standort, fragt die naechstgelegene Station direkt per api/nearby.php ab (ORDER BY Entfernung 3196// ASC, siehe dortige Doku - erste Zeile ist die naechste) und springt direkt in deren 3197// Detailansicht, ohne den Umweg ueber Start/Suche/Liste. 3198async function quickJumpToNearest() { 3199 showLoader(true); 3200 if (!navigator.geolocation) { 3201 showLoader(false); 3202 showToast('GPS nicht verfügbar'); 3203 showView('home'); 3204 return; 3205 } 3206 navigator.geolocation.getCurrentPosition(async pos => { 3207 userLat = pos.coords.latitude; 3208 userLng = pos.coords.longitude; 3209 const r = currentMode === 'cng' ? 20 : 5; 3210 try { 3211 const res = await fetch(`api/nearby.php?lat=${userLat}&lng=${userLng}&mode=${currentMode}&r=${r}`); 3212 const data = await res.json(); 3213 if (data.ok && data.stations && data.stations.length) { 3214 currentStations = data.stations; 3215 lastSearchLat = userLat; lastSearchLng = userLng; lastSearchLabel = 'In der Nähe'; 3216 showDetail(data.stations[0]); 3217 } else { 3218 showToast(`Keine Tankstelle in ${r} km gefunden.`); 3219 showView('home'); 3220 } 3221 } catch (e) { 3222 showToast('Fehler: ' + e.message); 3223 showView('home'); 3224 } finally { 3225 showLoader(false); 3226 } 3227 }, err => { 3228 showLoader(false); 3229 showToast('Standort nicht verfügbar: ' + err.message); 3230 showView('home'); 3231 }, { timeout: 10000, enableHighAccuracy: true }); 3232} 3233 3234// ââ Suche ââ 3235document.getElementById('btn-search').addEventListener('click', () => { 3236 const q = searchInput.value.trim(); 3237 if (!q) { showToast('Bitte PLZ, Ort, Adresse, Kennzeichen oder Autobahn eingeben'); return; } 3238 fetchSearch(q); 3239}); 3240searchInput.addEventListener('keydown', e => { 3241 if (e.key === 'Enter') document.getElementById('btn-search').click(); 3242}); 3243 3244// ââ Mode-Tabs ââ 3245// Aktiven Mode-Button beim Start setzen 3246function setMode(mode) { 3247 if (!['lpg','cng','all'].includes(mode)) return; 3248 currentMode = mode; 3249 localStorage.setItem('gasdb_mode', currentMode); 3250 document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); 3251 // 18.07.26: Radius-Selektor beim Kraftstoff-Wechsel auf sinnvollen Default setzen 3252 const radiusSel = document.getElementById('gps-radius'); 3253 if (radiusSel) radiusSel.value = mode === 'cng' ? '20' : '5'; 3254} 3255 3256setMode(currentMode); 3257 3258document.querySelectorAll('.mode-btn').forEach(btn => { 3259 btn.addEventListener('click', () => { 3260 setMode(btn.dataset.mode); 3261 // Neu laden wenn schon Ergebnisse da 3262 if (currentStations.length || activeView === 'list') { 3263 if (userLat) fetchNearby(userLat, userLng); 3264 else { const q = searchInput.value.trim(); if (q) fetchSearch(q); } 3265 } 3266 }); 3267}); 3268 3269// 28.08.2026 (PLAN Tankbuch): Login-Gate ist hier nur kosmetisch (zeigt Hinweis statt Formular) - 3270// der eigentliche Schutz liegt serverseitig in mobile/api/tankbuch.php (prueft $userindex). 3271function showTankbuchView() { 3272 showView('tankbuch'); 3273 const hint = document.getElementById('tankbuch-login-hint'); 3274 const content = document.getElementById('tankbuch-content'); 3275 if (currentUser) { 3276 hint.style.display = 'none'; 3277 content.style.display = ''; 3278 // 28.08.2026 (User-Wunsch: "die PWA nur zur Eingabe/Kontrolle der letzten 3 Tankvorgaenge, 3279 // Bearbeiten auf die Website verweisen") - eingeschraenkte Optionen fuer den PWA-Kontext, 3280 // siehe initTankbuch(opts) in js/tankbuch.js. Desktop (tankbuch.php) ruft ohne Optionen auf 3281 // = volle Darstellung (kein Limit, Bearbeiten erlaubt). 3282 // allowVehicleManage:false (Zweitfahrzeug-Erweiterung, User-Wunsch: "Bezeichner lasse
3282n sich 3283 // nur auf der Website anpassen um das Interface der PWA minimal zu halten") - Fahrzeuge 3284 // anlegen/umbenennen/loeschen geht nur ueber tankbuch.php, die PWA zeigt nur die Auswahl. 3285 if (window.initTankbuch) initTankbuch({ limit: 3, allowEdit: false, moreLink: '/tankbuch.php', allowVehicleManage: false }); 3286 } else { 3287 hint.style.display = ''; 3288 content.style.display = 'none'; 3289 } 3290} 3291 3292// ââ Bottom Nav ââ 3293document.getElementById('btn-open-help')?.addEventListener('click', () => showView('help')); 3294document.getElementById('btn-close-help')?.addEventListener('click', () => showView('home')); 3295 3296document.querySelectorAll('.nav-btn').forEach(btn => { 3297 btn.addEventListener('click', () => { 3298 // 05.09.2026: ein bewusster Wechsel ins normale Bottom-Nav beendet den Quick-Jump-Loop 3299 // (siehe _quickJump/showQuickJumpAgain oben) - sonst wuerde ein spaeteres Preis-Update in der 3300 // normalen App-Navigation ueberraschend wieder das gruene Wiederholungs-Overlay zeigen. 3301 _quickJump = false; 3302 const target = btn.dataset.view; 3303 if (target === 'route') { showView('route'); return; } 3304 if (target === 'scanner') { showView('scanner'); return; } 3305 if (target === 'profile') { showView('profile'); return; } 3306 if (target === 'tankbuch') { showTankbuchView(); return; } 3307 if (target === 'list' && currentStations.length) { showView('list'); return; } 3308 if (target === 'map' && currentStations.length) { showView('map'); return; } 3309 showView('home'); 3310 }); 3311}); 3312 3313// ââ Langer Fingerdruck auf "Liste"/"Karte" im Bottom-Nav â Umkreissuche am aktuellen 3314// GPS-Standort (User-Idee) - gleicher Grundansatz (Touch-Timer, 550ms, Vibration) wie der 3315// bestehende Long-Press auf der Karte selbst (siehe "Langer Fingerdruck auf die Karte" oben), 3316// nutzt aber navigator.geolocation statt einer angetippten Kartenposition, da ein Nav-Button 3317// keine Koordinate hat. Bei "Liste" UND "Karte" wird die Such-/Modusleiste au
3317sgeblendet 3318// ("maximale Screensize" - User-Wunsch), analog zur bestehenden Route-/Scanner-View-Behandlung 3319// in showView(). Ein anschliessender Klick nach ausgeloestem Long-Press wird unterdrueckt, sonst 3320// wuerde der normale Klick-Handler oben direkt danach nochmal (ungewollt) navigieren. 3321(function () { 3322 document.querySelectorAll('.nav-btn').forEach(btn => { 3323 const view = btn.dataset.view; 3324 if (view !== 'list' && view !== 'map') return; 3325 let pressTimer = null; 3326 let longPressed = false; 3327 3328 btn.addEventListener('touchstart', () => { 3329 longPressed = false; 3330 pressTimer = setTimeout(() => { 3331 pressTimer = null; 3332 longPressed = true; 3333 if (navigator.vibrate) navigator.vibrate(30); 3334 if (!navigator.geolocation) { showToast('GPS nicht verfügbar'); return; } 3335 showLoader(true); 3336 navigator.geolocation.getCurrentPosition( 3337 async pos => { 3338 userLat = pos.coords.latitude; 3339 userLng = pos.coords.longitude; 3340 showLoader(false); 3341 await fetchNearby(userLat, userLng); 3342 showView(view); 3343 // 11.08.2026 User-Wunsch: "bei der Karte dann auch den maximalen Bereich nutzen" - 3344 // gleiche Behandlung wie bei "Liste" oben, auch fuer die Karten-View. 3345 const searchBar = document.getElementById('search-bar'); 3346 const modeBar = document.getElementById('mode-bar'); 3347 if (searchBar) searchBar.style.display = 'none'; 3348 document.getElementById('search-filter-panel').style.display = 'none'; 3349 if (modeBar) modeBar.style.display = 'none'; 3350 }, 3351 err => { 3352 showLoader(false); 3353 showToast('Standort nicht verfügbar: ' + err.message); 3354 }, 3355 { timeout: 10000, enableHighAccuracy: true } 3356 ); 3357 }, 550); 3358 }, { passive: true }); 3359 3360 ['touchend', 'touchcancel', 'touchmove'].forEach(ev => btn.addEventListener(ev, () => { 3361 if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } 3362 }, { passive: true })); 3363 3364 btn.addEventListener('click', (e) => { 3365 if (longPressed) { e.preventDefault(); e.stopPropagation(); longPressed = false; } 3366 }, true); 3367 }); 3368})(); 3369 3370// ââ Radius-Stufen auf der Karte (+/-) ââ 3371// 18.07.26: User-Wunsch - direkt auf der Karte zur naechsten/vorherigen 3372// Radius-Stufe springen, statt zurueck zur Startseite zum Selektor zu muessen. 3373function updateMapRadiusUI(r) { 3374 const label = document.getElementById('map-radius-label'); 3375 if (label) label.textContent = (Number.isInteger(r) ? r : String(r).replace('.', ',')) + ' km'; 3376 const idx = RADIUS_STEPS.indexOf(r); 3377 const minusBtn = document.getElementById('map-radius-minus'); 3378 const plusBtn = document.getElementById('map-radius-plus'); 3379 if (minusBtn) minusBtn.disabled = (idx <= 0); 3380 if (plusBtn) plusBtn.disabled = (idx < 0 || idx >= RADIUS_STEPS.length - 1); 3381} 3382 3383function stepRadius(delta) { 3384 const radiusSel = document.getElementById('gps-radius'); 3385 if (!radiusSel) return; 3386 const idx = RADIUS_STEPS.indexOf(parseFloat(radiusSel.value)); 3387 const newIdx = idx + delta; 3388 if (newIdx < 0 || newIdx >= RADIUS_STEPS.length) return; 3389 radiusSel.value = String(RADIUS_STEPS[newIdx]); 3390 if (lastSearchLat !== null) { 3391 // renderList() springt sonst immer zur Listenansicht - beim Stufen-Wechsel 3392 // direkt auf der Karte soll die Karte aber sichtbar bleiben. 3393 fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel).then(() => showView('map')); 3394 } else { 3395 updateMapRadiusUI(RADIUS_STEPS[newIdx]); 3396 } 3397} 3398 3399document.getElementById('map-radius-minus').addEventListener('click', () => stepRadius(-1)); 3400document.getElementById('map-radius-plus').addEventListener('click', () => stepRadius(1)); 3401 3402// ââââââââââââââââââââââââââââââââââââââââââââââ 3403// ââ ROUTENPLANER ââ 3404// ââââââââââââââââââââââââââââââââââââââââââââââ 3405 3406let routeMode = localStorage.getItem('routeMode') || 'lpg'; 3407let routeRange = parseInt(localStorage.getItem('routeRange') || '350'); 3408// 18.09.2026 Merkmals-Filter der Routenansicht (siehe #route-filter-box), per localStorage gemerkt 3409function routeFilters() { 3410 return [...document.querySelectorAll('.route-filter-cb:checked')].map(cb => cb.value); 3411} 3412function routeUpdateFilterCount() { 3413 const n = routeFilters().length; 3414 document.getElementById('route-filter-count').textContent = n ? '(' + n + ')' : ''; 3415 try { localStorage.setItem('routeFilters', JSON.stringify(routeFilters())); } catch (e) {} 3416} 3417document.getElementById('btn-route-filter-toggle').addEventListener('click', () => { 3418 const box = document.getElementById('route-filter-box'); 3419 box.style.display = box.style.display === 'none' ? 'block' : 'none'; 3420}); 3421try { 3422 const savedF = JSON.parse(localStorage.getItem('routeFilters') || '[]'); 3423 document.querySelectorAll('.route-filter-cb').forEach(cb => { cb.checked = savedF.includes(cb.value); }); 3424} catch (e) {} 3425document.querySelectorAll('.route-filter-cb').forEach(cb => cb.addEventListener('change', routeUpdateFilterCount)); 3426routeUpdateFilterCount(); 3427
3428let routeCorridor = parseInt(localStorage.getItem('routeCorridor') || '15'); 3429let routeStartCoord = null; 3430let routeEndCoord = null; 3431routeMap = null; 3432let routeAvoidMotorway = false; 3433let routeWaypoints = []; 3434let wpSelectedCoord = null; 3435let routeStations = []; 3436 3437// Initialwerte ins Formular schreiben 3438document.getElementById('route-range').value = routeRange; 3439document.getElementById('route-corridor').value = routeCorridor; 3440document.querySelectorAll('.route-mode-btn').forEach(b => 3441 b.classList.toggle('active', b.dataset.rmode === routeMode)); 3442 3443// Gasart-Auswahl im Route-Formular 3444document.querySelectorAll('.route-mode-btn').forEach(btn => { 3445 btn.addEventListener('click', () => { 3446 document.querySelectorAll('.route-mode-btn').forEach(b => b.classList.remove('active')); 3447 btn.classList.add('active'); 3448 routeMode = btn.dataset.rmode; 3449 localStorage.setItem('routeMode', routeMode); 3450 }); 3451}); 3452 3453// ââ Adress-Autocomplete ââ 3454const _routeSearch = new mapkit.Search({ language: 'de' }); 3455 3456function setupRouteAutocomplete(inputEl, suggEl, onSelect) { 3457 let timer = null, req = null; 3458 inputEl.addEventListener('input', () => { 3459 clearTimeout(timer); 3460 const q = inputEl.value.trim(); 3461 if (q.length < 3) { suggEl.classList.remove('show'); return; } 3462 timer = setTimeout(() => { 3463 if (req) _routeSearch.cancel(req); 3464 req = _routeSearch.autocomplete(q, (err, data) => { 3465 if (err || !data.results.length) { suggEl.classList.remove('show'); return; } 3466 const items = data.results.slice(0, 6); 3467 suggEl.innerHTML = items.map((r, i) => 3468 `<div class="route-sugg-item" data-i="${i}"> 3469 <div class="route-sugg-main">${esc(r.displayLines[0] || '')}</div> 3470 ${r.displayLines[1] ? `<div class="route-sugg-sub">${esc(r.displayLines[1])}</div>` : ''} 3471 </div>` 3472 ).join(''); 3473 suggEl.classList.add('show'); 3474 suggEl.querySelectorAll('.route-sugg-item').forEach(el => { 3475 el.addEventListener('pointerdown', e => { 3476 e.preventDefault(); 3477 const r = items[+el.dataset.i]; 3478 if (!r.coordinate) { suggEl.classList.remove('show'); return; } 3479 inputEl.value = r.displayLines[0]; 3480 suggEl.classList.remove('show'); 3481 onSelect(new mapkit.Coordinate(r.coordinate.latitude, r.coordinate.longitude)); 3482 }); 3483 }); 3484 }); 3485 }, 320); 3486 }); 3487 inputEl.addEventListener('blur', () => setTimeout(() => suggEl.classList.remove('show'), 150)); 3488} 3489 3490setupRouteAutocomplete( 3491 document.getElementById('route-start-input'), 3492 document.getElementById('route-start-sugg'), 3493 coord => { routeStartCoord = coord; } 3494); 3495setupRouteAutocomplete( 3496 document.getElementById('route-end-input'), 3497 document.getElementById('route-end-sugg'), 3498 coord => { routeEndCoord = coord; } 3499); 3500 3501// GPS als Start 3502document.getElementById('btn-gps-route-inline').addEventListener('click', () => { 3503 if (!navigator.geolocation) { showToast('GPS nicht verfügbar'); return; } 3504 navigator.geolocation.getCurrentPosition(pos => { 3505 routeStartCoord = new mapkit.Coordinate(pos.coords.latitude, pos.coords.longitude); 3506 document.getElementById('route-start-input').value = 'ð Mein Standort'; 3507 }, () => showToast('Standort nicht ermittelt')); 3508}); 3509 3510// ââ Zwischenstopps ââ 3511function renderWaypointList() { 3512 const list = document.getElementById('route-wp-list'); 3513 const addBtn = document.getElementById('btn-add-wp'); 3514 if (!routeWaypoints.length) { 3515 list.style.display = 'none'; 3516 list.innerHTML = ''; 3517 } else { 3518 list.style.display = 'block'; 3519 list.innerHTML = routeWaypoints.map((wp, i) => 3520 '<div class="wp-item">' + 3521 '<span class="wp-num">' + (i + 1) + '</span>' + 3522 '<span class="wp-label">' + esc(wp.label) + '</span>' + 3523 '<button class="wp-del" data-i="' + i + '" aria-label="Entfernen">â</button>' + 3524 '</div>' 3525 ).join(''); 3526 list.querySelectorAll('.wp-del').forEach(function(btn) { 3527 btn.addEventListener('click', function() { 3528 routeWaypoints.splice(+btn.dataset.i, 1); 3529 renderWaypointList(); 3530 }); 3531 }); 3532 } 3533 addBtn.style.display = routeWaypoints.length >= 5 ? 'none' : ''; 3534} 3535 3536function openWpSheet() { 3537 document.getElementById('wp-backdrop').style.display = 'block'; 3538 document.getElementById('wp-sheet').classList.add('wp-open'); 3539 document.getElementById('wp-input').value = ''; 3540 document.getElementById('wp-add-btn').disabled = true; 3541 wpSelectedCoord = null; 3542 setTimeout(function() { document.getElementById('wp-input').focus(); }, 280); 3543} 3544 3545function closeWpSheet() { 3546 document.getElementById('wp-backdrop').style.display = 'none'; 3547 document.getElementById('wp-sheet').classList.remove('wp-open'); 3548 document.getElementById('wp-sugg').classList.remove('show'); 3549} 3550 3551document.getElementById('btn-add-wp').addEventListener('click', openWpSheet); 3552document.getElementById('wp-backdrop').addEventListener('click', closeWpSheet); 3553document.getElementById('wp-sheet-close').addEventListener('click', closeWpSheet); 3554document.getElementById('wp-add-btn').addEventListener('click', function() { 3555 if (!wpSelectedCoord) return; 3556 routeWaypoints.push({ coord: wpSelectedCoord, label: document.getElementById('wp-input').value }); 3557 closeWpSheet(); 3558 renderWaypointList(); 3559}); 3560setupRouteAutocomplete( 3561 document.getElementById('wp-input'), 3562 document.getElementById('wp-sugg'), 3563 function(coord) { 3564 wpSelectedCoord = coord; 3565 document.getElementById('wp-add-btn').disabled = false;
3566 } 3567); 3568 3569// ââ Autobahn-Toggle ââ 3570document.getElementById('route-avoid-motorway').addEventListener('change', function() { 3571 routeAvoidMotorway = this.checked; 3572}); 3573 3574// ââ Algorithmus-Hilfsfunktionen ââ 3575function haversineKm(lat1, lng1, lat2, lng2) { 3576 const R = 6371, r = Math.PI / 180; 3577 const dLat = (lat2 - lat1) * r, dLng = (lng2 - lng1) * r; 3578 const a = Math.sin(dLat/2)**2 + Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLng/2)**2; 3579 return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 3580} 3581 3582// Geradlinige Interpolation als Fallback wenn polyline.points leer 3583function interpolateGreatCircle(startC, endC, n) { 3584 const pts = []; 3585 for (let i = 0; i <= n; i++) { 3586 const t = i / n; 3587 pts.push({ latitude: startC.latitude + t*(endC.latitude-startC.latitude), 3588 longitude: startC.longitude + t*(endC.longitude-startC.longitude) }); 3589 } 3590 return pts; 3591} 3592 3593function buildCumDist(coords) { 3594 const cum = [0]; 3595 for (let i = 1; i < coords.length; i++) 3596 cum.push(cum[i-1] + haversineKm(coords[i-1].latitude, coords[i-1].longitude, 3597 coords[i].latitude, coords[i].longitude)); 3598 return cum; 3599} 3600 3601function sampleRoute(coords, cumDist, stepKm) { 3602 const pts = [{ lat: coords[0].latitude, lng: coords[0].longitude }]; 3603 let next = stepKm; 3604 for (let i = 1; i < cumDist.length; i++) { 3605 if (cumDist[i] >= next) { 3606 pts.push({ lat: coords[i].latitude, lng: coords[i].longitude }); 3607 next += stepKm; 3608 } 3609 } 3610 const last = coords[coords.length - 1]; 3611 pts.push({ lat: last.latitude, lng: last.longitude }); 3612 return pts; 3613} 3614 3615function getRouteKm(s, coords, cumDist) { 3616 let minD = Infinity, bestI = 0; 3617 for (let i = 0; i < coords.length; i++) { 3618 const d = haversineKm(s.lat, s.lng, coords[i].latitude, coords[i].longitude); 3619 if (d < minD) { minD = d; bestI = i; } 3620 } 3621 return cumDist[bestI]; 3622} 3623 3624// Greedy: minimale Stopp-Anzahl 3625function findOptimalStops(stations, totalKm, effRange) { 3626 if (effRange <= 0) return { stops: [], error: 'Effektive Reichweite nach Reserve zu gering.' }; 3627 const stops = []; 3628 let pos = 0; 3629 while (pos + effRange < totalKm) { 3630 const reach = stations.filter(s => s.routeKm > pos && s.routeKm <= pos + effRange); 3631 if (!reach.length) 3632 return { stops, error: `Keine Tankstelle erreichbar ab km ${Math.round(pos)} (Reichweite bis km ${Math.round(pos + effRange)}).` }; 3633 // Immer die am weitesten entfernte Station nehmen (Greedy â minimale Stopp-Anzahl) 3634 stops.push(reach[reach.length - 1]); 3635 pos = reach[reach.length - 1].routeKm; 3636 } 3637 return { stops, error: null }; 3638} 3639 3640// ââ Routing: OSRM (normal) / Valhalla (Autobahn vermeiden) ââ 3641function decodePolyline(str) { 3642 let i = 0, lat = 0, lng = 0, coords = []; 3643 while (i < str.length) { 3644 let b, shift = 0, result = 0; 3645 do { b = str.charCodeAt(i++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); 3646 lat += (result & 1) ? ~(result >> 1) : (result >> 1); 3647 shift = result = 0; 3648 do { b = str.charCodeAt(i++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); 3649 lng += (result & 1) ? ~(result >> 1) : (result >> 1); 3650 coords.push(new mapkit.Coordinate(lat / 1e6, lng / 1e6)); 3651 } 3652 return coords; 3653} 3654 3655async function calcRouteOSRM(coords, avoidMotorway) { 3656 if (!avoidMotorway) { 3657 const waypts = coords.map(c => c.longitude + ',' + c.latitude).join(';'); 3658 const res = await fetch('https://router.project-osrm.org/route/v1/driving/' + waypts + '?geometries=geojson&overview=full'); 3659 const data = await res.json(); 3660 if (!data.routes || !data.routes.length) throw new Error('Keine Route gefunden'); 3661 const route = data.routes[0]; 3662 const points = route.geometry.coordinates.map(c => new mapkit.Coordinate(c[1], c[0])); 3663 return { points, distanceKm: route.distance / 1000, durationSecs: route.duration }; 3664 } 3665 const locations = coords.map(c => ({ lon: c.longitude, lat: c.latitude })); 3666 const res = await fetch('https://valhalla1.openstreetmap.de/route', { 3667 method: 'POST', headers: { 'Content-Type': 'application/json' }, 3668 body: JSON.stringify({ locations, costing: 'auto', costing_options: { auto: { use_highways: 0.0 } } }) 3669 }); 3670 const data = await res.json(); 3671 if (!data.trip || !data.trip.legs || !data.trip.legs.length) throw new Error('Keine Route gefunden (Valhalla)'); 3672 let allPoints = [], totalKm = 0, totalSecs = 0;
3673 data.trip.legs.forEach(leg => { 3674 const pts = decodePolyline(leg.shape); 3675 allPoints = allPoints.length ? [...allPoints, ...pts.slice(1)] : [...pts]; 3676 totalKm += leg.summary.length; totalSecs += leg.summary.time; 3677 }); 3678 return { points: allPoints, distanceKm: totalKm, durationSecs: totalSecs }; 3679} 3680 3681// ââ Route berechnen ââ 3682document.getElementById('btn-route-calc').addEventListener('click', async () => { 3683 if (!routeStartCoord) { showToast('Bitte Start eingeben oder Standort nutzen'); return; } 3684 if (!routeEndCoord) { showToast('Bitte Ziel eingeben'); return; } 3685 3686 routeRange = Math.max(50, Math.min(800, parseInt(document.getElementById('route-range').value) || 350)); 3687 routeCorridor = Math.max(5, Math.min(30, parseInt(document.getElementById('route-corridor').value) || 15)); 3688 localStorage.setItem('routeRange', routeRange); 3689 localStorage.setItem('routeCorridor', routeCorridor); 3690 3691 const btn = document.getElementById('btn-route-calc'); 3692 btn.disabled = true; btn.innerHTML = '<span style="display:inline-block;width:17px;height:17px;border:2.5px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:route-spin .7s linear infinite;vertical-align:middle;margin-right:8px"></span>Berechneâ¦'; 3693 3694 try { 3695 const allCoords = [routeStartCoord, ...routeWaypoints.map(w => w.coord), routeEndCoord]; 3696 const { points: useCoords, distanceKm: totalKm, durationSecs: totalSecs } = 3697 await calcRouteOSRM(allCoords, routeAvoidMotorway); 3698 3699 const cumDist = buildCumDist(useCoords); 3700 const stepKm = Math.max(routeCorridor * 1.5, 10); 3701 const waypoints = sampleRoute(useCoords, cumDist, stepKm); 3702 3703 let apiData; 3704 try { 3705 const res = await fetch('/mobile/api/route.php', { 3706 method: 'POST', 3707 headers: { 'Content-Type': 'application/json' }, 3708 body: JSON.stringify({ waypoints, corridor_km: routeCorridor, mode: routeMode, filters: routeFilters() }) 3709 }); 3710 apiData = await res.json(); 3711 } catch(e) { showToast('Verbindungsfehler'); return; } 3712 3713 if (!apiData.ok) { showToast('API-Fehler: ' + (apiData.error || '?')); return; } 3714 3715 routeStations = apiData.stations.map(s => ({ ...s, routeKm: getRouteKm(s, useCoords, cumDist) })); 3716 routeStations.sort((a, b) => a.routeKm - b.routeKm); 3717 3718 const { stops, error } = findOptimalStops(routeStations, totalKm, routeRange - 50); 3719 const nearStops = stops.length 3720 ? routeStations.filter(s => stops.some(st => haversineKm(s.lat, s.lng, st.lat, st.lng) <= routeCorridor)) 3721 : routeStations; 3722 renderRouteResults(useCoords, nearStops, stops, totalKm, totalSecs, error); 3723 saveRouteHistory(); 3724 } catch(e) { 3725 showToast('Route konnte nicht berechnet werden'); 3726 } finally { 3727 btn.disabled = false; btn.textContent = 'Route berechnen ð'; 3728 } 3729}); 3730 3731// ââ Routenverlauf ââ 3732let routeHistoryCache = []; 3733 3734async function loadRouteHistory() { 3735 if (!currentUser) { renderRouteHistoryChips([]); return; } 3736 try { 3737 const r = await fetch('/mobile/api/route_history.php'); 3738 const d = await r.json(); 3739 routeHistoryCache = (d.ok && d.routes) ? d.routes : []; 3740 } catch(e) { routeHistoryCache = []; } 3741 renderRouteHistoryChips(routeHistoryCache); 3742} 3743 3744function renderRouteHistoryChips(routes) { 3745 const row = document.getElementById('route-history-row'); 3746 const chips = document.getElementById('rh-chips'); 3747 if (!routes.length) { row.classList.remove('has-items'); return; } 3748 row.classList.add('has-items'); 3749 chips.innerHTML = routes.map(r => { 3750 const via = r.waypoints && r.waypoints.length 3751 ? ' <span style="color:var(--sub)">via ' + esc(r.waypoints.map(w=>w.label).join(', ')) + '</span>' : ''; 3752 const label = esc(r.start.label) + ' â ' + esc(r.end.label); 3753 return '<div class="rh-chip" data-id="' + r.id + '">' + 3754 '<span class="rh-chip-text" title="' + label + '">' + label + via + '</span>' + 3755 '<button class="rh-chip-del" data-id="' + r.id + '" aria-label="Löschen">â</button>' + 3756 '</div>'; 3757 }).join(''); 3758 chips.querySelectorAll('.rh-chip').forEach(chip => { 3759 chip.addEventListener('click', function(e) { 3760 if (e.target.classList.contains('rh-chip-del')) return; 3761 const id = +chip.dataset.id; 3762 const route = routes.find(r => r.id === id); 3763 if (route) applyHistoryRoute(route); 3764 }); 3765 }); 3766 chips.querySelectorAll('.rh-chip-del').forEach(btn => { 3767 btn.addEventListener('click', async function(e) { 3768 e.stopPropagation(); 3769 const id = +btn.dataset.id; 3770 await fetch('/mobile/api/route_history.php', { 3771 method: 'DELETE', headers: {'Content-Type':'application/json'}, 3772 body: JSON.stringify({id}) 3773 }); 3774 routeHistoryCache = routeHistoryCache.filter(r => r.id !== id); 3775 renderRouteHistoryChips(routeHistoryCache); 3776 }); 3777 }); 3778} 3779 3780function applyHistoryRoute(route) { 3781 document.getElementById('route-start-input').value = route.start.label; 3782 routeStartCoord = new mapkit.Coordinate(route.start.lat, route.
3782start.lng); 3783 document.getElementById('route-end-input').value = route.end.label; 3784 routeEndCoord = new mapkit.Coordinate(route.end.lat, route.end.lng); 3785 routeWaypoints = (route.waypoints || []).map(wp => ({ 3786 label: wp.label, 3787 coord: new mapkit.Coordinate(wp.lat, wp.lng) 3788 })); 3789 renderWaypointList(); 3790 routeAvoidMotorway = route.avoidMotorway || false; 3791 document.getElementById('route-avoid-motorway').checked = routeAvoidMotorway; 3792} 3793 3794function routesIdentical(a, b) { 3795 if (!a || !b) return false; 3796 const c3 = v => Math.round(v * 1000); 3797 if (c3(a.start.lat) !== c3(b.start.lat) || c3(a.start.lng) !== c3(b.start.lng)) return false; 3798 if (c3(a.end.lat) !== c3(b.end.lat) || c3(a.end.lng) !== c3(b.end.lng)) return false; 3799 if ((a.waypoints||[]).length !== (b.waypoints||[]).length) return false; 3800 if (!!a.avoidMotorway !== !!b.avoidMotorway) return false; 3801 return (a.waypoints||[]).every((w,i) => { 3802 const v = b.waypoints[i]; 3803 return c3(w.lat) === c3(v.lat) && c3(w.lng) === c3(v.lng); 3804 }); 3805} 3806 3807async function saveRouteHistory() { 3808 if (!currentUser || !routeStartCoord || !routeEndCoord) return; 3809 const route = { 3810 start: { label: document.getElementById('route-start-input').value, 3811 lat: routeStartCoord.latitude, lng: routeStartCoord.longitude }, 3812 end: { label: document.getElementById('route-end-input').value, 3813 lat: routeEndCoord.latitude, lng: routeEndCoord.longitude }, 3814 waypoints: routeWaypoints.map(w => ({ 3815 label: w.label, 3816 lat: w.coord.latitude, lng: w.coord.longitude })), 3817 avoidMotorway: routeAvoidMotorway 3818 }; 3819 if (routeHistoryCache.length && routesIdentical(route, routeHistoryCache[0])) return; 3820 try { 3821 await fetch('/mobile/api/route_history.php', { 3822 method: 'POST', headers: {'Content-Type':'application/json'}, 3823 body: JSON.stringify({route}) 3824 }); 3825 await loadRouteHistory(); 3826 } catch(e) {} 3827} 3828 3829// ââ Ergebnis rendern ââ 3830function renderRouteResults(useCoords, stations, stops, totalKm, totalSecs, error) { 3831 document.getElementById('route-form-panel').style.display = 'none'; 3832 document.getElementById('route-result-panel').style.display = 'flex'; 3833 3834 // Karte initialisieren / leeren 3835 if (!routeMap) { 3836 routeMap = new mapkit.Map('route-map-container', { 3837 showsCompass: mapkit.FeatureVisibility.Hidden, 3838 showsZoomControl: false, 3839 showsUserLocation: true, 3840 colorScheme: pwaMkColorScheme(), 3841 }); 3842 } else { 3843 routeMap.removeAnnotations(routeMap.annotations); 3844 routeMap.removeOverlays(routeMap.overlays); 3845 } 3846 3847 // Route als Linie 3848 routeMap.addOverlay(new mapkit.PolylineOverlay(useCoords, { 3849 style: new mapkit.Style({ lineWidth: 4, strokeColor: '#3b82f6' }) 3850 })); 3851 3852 // Durchschnittspreis für Farbgebung 3853 const prices = stations.map(s => routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng)).filter(Boolean); 3854 const avgP = prices.length ? prices.reduce((a,b) => a+b, 0) / prices.length : null; 3855 3856 // Pins: empfohlene Stopps als MarkerAnnotation (groÃ, nummeriert) 3857 const stopIds = new Set(stops.map(s => s.id)); 3858 const stopAnns = []; 3859 stops.forEach((s, i) => { 3860 stopAnns.push(new mapkit.MarkerAnnotation(new mapkit.Coordinate(s.lat, s.lng), { 3861 color: '#e87800', 3862 glyphText: (i + 1).toString(), 3863 title: s.name, 3864 subtitle: (() => { const p = routeMode==='cng'?s.cng:(s.lpg??s.cng); return p?p.toFixed(3)+' '+priceUnit(s)+'/L':''; })(), 3865 })); 3866 }); 3867 3868 // Korridor-Stationen als kleine ImageAnnotation (Punkt) 3869 const corrAnns = []; 3870 stations.filter(s => !stopIds.has(s.id)).forEach(s => { 3871 const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng); 3872 const ann = new mapkit.MarkerAnnotation(new mapkit.Coordinate(s.lat, s.lng), { 3873 color: pinColor(price, avgP), 3874 glyphText: '·', 3875 glyphColor: '#fff', 3876 title: s.name, 3877 displayPriority: 250, 3878 }); 3879 corrAnns.push(ann); 3880 }); 3881 3882 routeMap.addAnnotations([...corrAnns, ...stopAnns]); 3883 3884 // Karte nur auf Route-Linie + Stopps zoomen (nicht auf alle 199 Korridor-Stationen) 3885 const fitItems = stopAnns.length 3886 ? [...stopAnns, ...routeMap.overlays] 3887 : routeMap.overlays; 3888 if (fitItems.length) routeMap.showItems(fitItems, { animate: false, padding: new mapkit.Padding(40,40,40,40) }); 3889 3890 // Fehler 3891 const errEl = document.getElementById('route-error-msg'); 3892 if (error) { errEl.textContent = 'â ï¸ ' + error; errEl.style.display = 'block'; } 3893 else errEl.style.display = 'none'; 3894 3895 // Info-Zeile 3896 const mins = Math.round(totalSecs / 60); 3897 const timeStr = mins >= 60 ? `${Math.floor(mins/60)}h ${mins%60}min` : `${mins} min`; 3898 document.getElementById('route-info-bar').innerHTML = 3899 `<span>${Math.round(totalKm)} km · ${timeStr} · ${stations.length} Stationen · ${stops.length} Stopp${stops.length !== 1 ? 's' : ''}</span>` 3900 + (stops.length ? '<button id="btn-route-print" aria-label="Route drucken" title="Route drucken">ð¨ï¸</button>' : ''); 3901 const printBtn = document.getElementById('btn-route-print'); 3902 if (printBtn) printBtn.addEventListener('click', () => printSheet('route-print-sheet')); 3903 3904 // Druckansicht (10.08.26, User-Wunsch: "Auch dort (es gibt ja auch Tablets) eine druckbare 3905 // Liste anbieten"), analog zum Desktop-Routenplaner. Drucker-Symbol statt vollem Button auf 3906 // User-Wunsch rechts neben der Info-Zeile platziert. 3907 const startTxt = document.getElementById('route-start-input').value || 'Start';
3908 const endTxt = document.getElementById('route-end-input').value || 'Ziel'; 3909 document.getElementById('route-print-sheet').innerHTML = !stops.length ? '' : ` 3910 <h1>Route: ${esc(startTxt)} â ${esc(endTxt)}</h1> 3911 <div class="rpp-meta">${Math.round(totalKm)} km · ${timeStr} · ${stops.length} Tankstopp${stops.length !== 1 ? 's' : ''} 3912 · ${routeMode === 'cng' ? 'CNG' : 'LPG'} · gedruckt am ${new Date().toLocaleDateString('de-DE')}</div> 3913 <table> 3914 <thead><tr><th></th><th>Tankstelle</th><th>Adresse</th><th>km</th><th>Preis</th><th>Info</th></tr></thead> 3915 <tbody> 3916 ${stops.map((s, i) => { 3917 const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng); 3918 const addr = [s.strasse, [s.plz, s.ort].filter(Boolean).join(' ')].filter(Boolean).join(', '); 3919 const info = [s.h24 ? '24h' : '', s.d7 ? '7/7' : ''].filter(Boolean).join(' · '); 3920 return `<tr> 3921 <td><span class="rpp-num">${i+1}</span></td> 3922 <td>${esc(s.name)}</td> 3923 <td>${esc(addr)}</td> 3924 <td>${Math.round(s.routeKm)}</td> 3925 <td>${price ? price.toFixed(3)+' '+priceUnit(s)+'/L' : 'â'}</td> 3926 <td>${esc(info)}</td> 3927 </tr>`; 3928 }).join('')} 3929 </tbody> 3930 </table>`; 3931 3932 // Empfohlene Stopps 3933 const stopsEl = document.getElementById('route-stops'); 3934 if (!stops.length && !error) { 3935 stopsEl.innerHTML = '<div class="route-no-stop">â Kein Tankstopp nötig â Ziel liegt in Reichweite!</div>'; 3936 } else { 3937 stopsEl.innerHTML = stops.map((s, i) => { 3938 const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng); 3939 return `<div class="route-stop-card" data-lat="${s.lat}" data-lng="${s.lng}"> 3940 <div class="route-stop-num">${i+1}</div> 3941 <div class="route-stop-body"> 3942 <div class="route-stop-name">${esc(s.name)}</div> 3943 ${s.strasse ? `<div class="route-stop-addr">${esc(s.strasse)}</div>` : ''} 3944 <div class="route-stop-addr">${esc(s.plz)} ${esc(s.ort)}</div> 3945 <div class="route-stop-meta"> 3946 <span class="km-badge">${Math.round(s.routeKm)} km</span> 3947 <span class="price-tag">${price ? price.toFixed(3)+' '+priceUnit(s)+'/L' : 'kein Preis'}</span> 3948 ${s.h24 ? '<span class="price-tag" style="background:#fef3c7;color:#92400e">24h</span>' : ''} 3949 ${s.d7 ? '<span class="price-tag" style="background:#f0fdf4;color:#166534">7/7</span>' : ''} 3950 </div> 3951 </div> 3952 </div>`; 3953 }).join(''); 3954 } 3955 3956 // Klick auf Stop-Card â Karte zoomen 3957 document.querySelectorAll('.route-stop-card[data-lat]').forEach(card => { 3958 card.addEventListener('click', () => { 3959 routeMap.region = new mapkit.CoordinateRegion( 3960 new mapkit.Coordinate(parseFloat(card.dataset.lat), parseFloat(card.dataset.lng)), 3961 new mapkit.CoordinateSpan(0.12, 0.14) 3962 ); 3963 }); 3964 }); 3965 3966 // Alle Stationen 3967 document.getElementById('route-all-list').innerHTML = stations.map(s => { 3968 const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng); 3969 return `<div class="route-station-row" data-lat="${s.lat}" data-lng="${s.lng}" style="cursor:pointer"> 3970 <div class="route-station-dot" style="background:${pinColor(price, avgP)}"></div> 3971 <div class="route-station-info"> 3972 <div class="route-station-name">${esc(s.name)}</div> 3973 <div class="route-station-addr">${s.strasse ? esc(s.strasse) + ' · ' : ''}${esc(s.plz)} ${esc(s.ort)} · ${Math.round(s.routeKm)} km</div> 3974 </div> 3975 <div style="font-size:.82rem;font-weight:700;color:#333;flex-shrink:0">${price ? price.toFixed(3) : 'â'}</div> 3976 </div>`; 3977 }).join(''); 3978 3979 // Klick auf Station â Karte zoomen 3980 document.querySelectorAll('.route-station-row[data-lat]').forEach(row => { 3981 row.addEventListener('click', () => { 3982 routeMap.region = new mapkit.CoordinateRegion( 3983 new mapkit.Coordinate(parseFloat(row.dataset.lat), parseFloat(row.dataset.lng)), 3984 new mapkit.CoordinateSpan(0.06, 0.08) 3985 ); 3986 }); 3987 }); 3988 3989 // Toggle "alle Stationen" 3990 document.getElementById('route-all-list').classList.remove('show'); 3991 document.getElementById('route-all-toggle').textContent = 'â¶ Alle Stationen im Korridor'; 3992} 3993 3994document.getElementById('route-all-toggle').addEventListener('click', () => { 3995 const el = document.getElementById('route-all-list'); 3996 const tog = document.getElementById('route-all-toggle'); 3997 el.classList.toggle('show'); 3998 tog.textContent = el.classList.contains('show') 3999 ? 'â¼ Alle Stationen ausblenden' 4000 : 'â¶ Alle Stationen im Korridor'; 4001}); 4002 4003document.getElementById('btn-route-reset').addEventListener('click', () => { 4004 document.getElementById('route-result-panel').style.display = 'none'; 4005 document.getElementById('route-form-panel').style.display = ''; 4006 document.getElementById('route-all-list').classList.remove('show'); 4007}); 4008 4009// ââ Auth ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4010 4011async function checkAuthStatus() { 4012 try { 4013 const r = await fetch('/mobile/api/auth.php?action=status'); 4014 const d = await r.json(); 4015 currentUser = (d.ok && d.loggedin) ? { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 } : null; 4016 } catch(e) { currentUser = null; } 4017 renderProfileView(); 4018} 4019 4020function renderProfileView() { 4021 const loginForm = document.getElementById('profile-login-form'); 4022 const loggedIn = document.getElementById('profile-loggedin'); 4023 const navBtn = document.querySelector('[data-view="profile"]'); 4024 const navLabel = document.getElementById('nav-profile-label'); 4025 const modeBar = document.getElementById('mode-bar'); 4026 // 28.08.2026 (User-Wunsch: "Tankbuch ausgrauen, wenn nicht angemeldet") - renderProfileView() 4027 // laeuft bei jeder currentUser-Aenderung (Login/Logout/OAuth-Callback), daher zentraler Ort 4028 // fuer diesen Zustandsabgleich statt an jeder einzelnen currentUser-Zuweisung zu wiederholen. 4029 const tankbuchBtn = document.querySelector('.nav-btn[data-view="tankbuch"]'); 4030 if (tankbuchBtn) tankbuchBtn.classList.toggle('nav-btn-locked', !currentUser); 4031 if (currentUser) { 4032 loginForm.style.display = 'none'; 4033 loggedIn.style.display = ''; 4034 const username = currentUser.user; 4035 const realname = currentUser.vorname; 4036 document.getElementById('profile-name').textContent = username + (currentUser.is_admin ? ' [' + currentUser.is_admin + ']' : ''); 4037 const realnameEl = document.getElementById('profile-realname'); 4038 if (realnameEl) { 4039 realnameEl.textContent = (realname && realname !== username) ? realname : ''; 4040 realnameEl.style.display = realnameEl.textContent ? '' : 'none'; 4041 } 4042 document.getElementById('profile-avatar').textContent = username.charAt(0).toUpperCase(); 4043 if (navLabel) navLabel.textContent = username.substring(0, 6) + 'â¦'; 4044 if (navBtn) navBtn.classList.add('is-logged-in'); 4045 // Gas-Typ aus Profil übernehmen und Mode-Bar ausblenden 4046 const lockedGas = currentUser.gas || localStorage.getItem('gasdb_gas_locked'); 4047 if (lockedGas) { 4048 if (currentUser.gas) setMode(currentUser.gas); 4049 if (modeBar) modeBar.style.display = 'none'; 4050 currentUser.gas = currentUser.gas || lockedGas; 4051 // Routenplaner-Gasart setzen und Auswahl ausblenden 4052 routeMode = currentUser.gas; 4053 localStorage.setItem('routeMode', routeMode); 4054 document.querySelectorAll('.route-mode-btn').forEach(b => 4055 b.classList.toggle('active', b.dataset.rmode === routeMode)); 4056 const routeModeRow = document.querySelector('.route-mode-row'); 4057 if (routeModeRow) routeModeRow.style.display = 'none'; 4058 document.querySelectorAll('.gas-pick-btn').forEach(b => 4059 b.classList.toggle('active', b.dataset.gas === lockedGas)); 4060 } 4061 // Favoriten-Tabs einblenden 4062 const favTabs = document.getElementById('fav-tabs'); 4063 if (favTabs) favTabs.style.display = ''; 4064 loadRouteHistory(); 4065 // Admins: Spenden-Karte durch Kennzahlen-Karte ersetzen 4066 const donateCard = document.getElementById('profile-donate-card'); 4067 const adminCard = document.getElementById('profile-admin-card'); 4068 if (currentUser.is_admin) { 4069 if (donateCard) donateCard.style.display = 'none'; 4070 if (adminCard) adminCard.style.display = ''; 4071 loadAdminStats(); 4072 } else { 4073 if (donateCard) donateCard.style.display = ''; 4074 if (adminCard) adminCard.style.display = 'none'; 4075 } 4076 } else { 4077 loginForm.style.display = ''; 4078 loggedIn.style.display = 'none'; 4079 if (navLabel) navLabel.textContent = 'Profil'; 4080 if (navBtn) navBtn.classList.remove('is-logged-in'); 4081 // Mode-Bar und Route-Gasauswahl wieder anzeigen (auÃer Route-View) 4082 if (modeBar && activeView !== 'route') modeBar.style.display = ''; 4083 const routeModeRow = document.querySelector('.route-mode-row'); 4084 if (routeModeRow) routeModeRow.style.display = ''; 4085 // Tabs ausblenden, auf Lokal zurücksetzen 4086 const favTabs = document.getElementById('fav-tabs'); 4087 if (favTabs) favTabs.style.display = 'none'; 4088 if (activeFavTab === 'profile') { 4089 activeFavTab = 'local'; 4090 document.querySelectorAll('.fav-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'local')); 4091 } 4092 profileFavCache = null; 4093 renderFavoritesHome(); 4094 } 4095} 4096 4097async function loadAdminStats() { 4098 try { 4099 const r = await fetch('/mobile/api/admin_stats.php'); 4100 const d = await r.json(); 4101 if (!d.ok) return; 4102 document.getElementById('stat-pageviews').textContent = d.pageviews_24h; 4103 document.getElementById('stat-apicalls').textContent = d.api_calls_24h; 4104 document.getElementById('stat-activeusers').textContent = d.aktive_user_30d; 4105 document.getElementById('stat-loginpct').textContent = d.login_user_pct + '%'; 4106 } catch(e) { /* Admin-Kennzahlen optional, still bei Fehler */ } 4107} 4108 4109let profileOtpPending = false;
4110 4111document.getElementById('btn-do-login').addEventListener('click', async () => { 4112 const username = document.getElementById('profile-username').value.trim(); 4113 const password = document.getElementById('profile-password').value; 4114 const otpField = document.getElementById('profile-otp-field'); 4115 const otpCode = document.getElementById('profile-otp-code').value.trim(); 4116 const errEl = document.getElementById('profile-error'); 4117 errEl.style.display = 'none'; 4118 if (!username || !password) { 4119 errEl.textContent = 'Bitte Benutzername und Passwort eingeben'; 4120 errEl.style.display = 'block'; 4121 return; 4122 } 4123 if (profileOtpPending && !otpCode) { 4124 errEl.textContent = 'Bitte 2FA-Code eingeben'; 4125 errEl.style.display = 'block'; 4126 return; 4127 } 4128 const btn = document.getElementById('btn-do-login'); 4129 btn.disabled = true; btn.textContent = 'Anmeldenâ¦'; 4130 try { 4131 const fd = new FormData(); 4132 fd.append('action', 'login'); fd.append('username', username); fd.append('password', password); 4133 if (profileOtpPending) fd.append('otp_code', otpCode); 4134 const r = await fetch('/mobile/api/auth.php', { method: 'POST', body: fd }); 4135 const d = await r.json(); 4136 if (d.ok) { 4137 currentUser = { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 }; 4138 document.getElementById('profile-password').value = ''; 4139 document.getElementById('profile-otp-code').value = ''; 4140 profileOtpPending = false; 4141 otpField.style.display = 'none'; 4142 renderProfileView(); 4143 showToast('Willkommen, ' + (d.vorname || d.user) + '!'); 4144 } else if (d.need_2fa) { 4145 profileOtpPending = true; 4146 otpField.style.display = ''; 4147 document.getElementById('profile-otp-code').focus(); 4148 errEl.textContent = d.error || 'Bitte 2FA-Code eingeben'; 4149 errEl.style.display = 'block'; 4150 } else { 4151 errEl.textContent = d.error || 'Anmeldung fehlgeschlagen'; 4152 // 20.08.2026 Bugfix (der wahre Kern-Bug hinter "keine Fehlermeldung" beim knufloti-/ 4153 // Njumaen-Login-Debugging!): errEl.style.display='' entfernt nur einen etwaigen Inline- 4154 // Style, ueberschreibt aber NICHT die CSS-Klassenregel ".p-error { display: none; }" - 4155 // computed display blieb "none", die Fehlermeldung stand zwar korrekt im DOM (textContent), 4156 // war aber nie sichtbar. Live auf Prod im Browser reproduziert (Netzwerk-Antwort enthielt 4157 // den korrekten Fehlertext, sichtbar war nichts). 'block' ueberschreibt die Klassenregel 4158 // korrekt per Inline-Style. 4159 errEl.style.display = 'block'; 4160 } 4161 } catch(e) { 4162 errEl.textContent = 'Verbindungsfehler'; 4163 errEl.style.display = 'block'; 4164 } 4165 btn.disabled = false; btn.textContent = 'Anmelden'; 4166}); 4167 4168document.getElementById('btn-do-logout').addEventListener('click', async () => { 4169 const fd = new FormData(); fd.append('action', 'logout'); 4170 await fetch('/mobile/api/auth.php', { method: 'POST', body: fd }); 4171 currentUser = null; 4172 renderProfileView(); 4173 showToast('Abgemeldet'); 4174}); 4175 4176// Eingabe mit Enter bestätigen 4177document.getElementById('profile-password').addEventListener('keydown', e => { 4178 if (e.key === 'Enter') document.getElementById('btn-do-login').click(); 4179}); 4180document.getElementById('profile-otp-code').addEventListener('keydown', e => { 4181 if (e.key === 'Enter') document.getElementById('btn-do-login').click(); 4182}); 4183// Passwort nachtraeglich geaendert -> pending 2FA-Status verwerfen (sonst wuerde ein alter Code 4184// gegen einen neuen Login-Versuch mitgeschickt) 4185document.getElementById('profile-password').addEventListener('input', () => { 4186 if (profileOtpPending) { 4187 profileOtpPending = false; 4188 document.getElementById('profile-otp-field').style.display = 'none'; 4189 document.getElementById('profile-otp-code').value = ''; 4190 } 4191}); 4192 4193// ââ OAuth: gemeinsamer Handler nach erfolgreichem Login ââââââââââââââââââââââ 4194function handleOAuthSuccess(d) { 4195 currentUser = { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 }; 4196 document.getElementById('oauth-link-box').style.display = 'none'; 4197 renderProfileView(); 4198 showToast('Willkommen, ' + (d.vorname || d.user) + '!'); 4199 // Gas-Typ aus erster Suche: bereits gesetzt? â Mode-Bar ausblenden 4200 if (d.gas && !currentUser.gas) autoSaveGas(d.gas); 4201} 4202 4203async function oauthPost(body) { 4204 const fd = new FormData(); 4205 for (const [k, v] of Object.entries(body)) fd.append(k, v); 4206 const r = await fetch('/mobile/api/oauth.php', { method: 'POST', body: fd }); 4207 return await r.json(); 4208} 4209 4210// Verknüpfungsdialog: Sub + Provider zwischenspeichern 4211let oauthPending = null; 4212 4213function showLinkBox(d) { 4214 oauthPending = { provider: d.provider, sub: d.sub, email: d.email }; 4215 document.getElementById('oauth-link-msg').textContent = 4216 `E-Mail â${d.email}" ist bereits registriert. Passwort eingeben um Konten zu verknüpfen.`; 4217 document.getElementById('oauth-link-pw').value = ''; 4218 document.getElementById('oauth-link-error').textContent = ''; 4219 document.getElementById('oauth-link-box').style.display = ''; 4220 document.getElementById('oauth-link-pw').focus(); 4221} 4222 4223document.getElementById('btn-oauth-link').addEventListener('click', async () => { 4224 if (!oauthPending) return; 4225 const pw = document.getElementById('oauth-link-pw').value; 4226 const errEl = document.getElementById('oauth-link-error'); 4227 if (!pw) { errEl.textContent = 'Passwort eingeben'; return; } 4228 const btn = document.getElementById('btn-oauth-link'); 4229 btn.disabled = true; btn.textContent = 'Prüfeâ¦'; 4230 try { 4231 const d = await oauthPost({ action: 'link', ...oauthPending, password: pw }); 4232 if (d.ok) { oauthPending = null; handleOAuthSuccess(d); } 4233 else { errEl.textContent = d.error || 'Fehler'; } 4234 } catch(e) { errEl.textContent = 'Verbindungsfehler'; } 4235 btn.disabled = false; btn.textContent = 'Verknüpfen & anmelden'; 4236}); 4237 4238document.getElementById('btn-oauth-link-cancel').addEventListener('click', () => { 4239 oauthPending = null; 4240 document.getElementById('oauth-link-box').style.display = 'none'; 4241}); 4242 4243// ââ Sign in with Apple ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4244document.getElementById('btn-apple-signin').addEventListener('click', async () => { 4245 if (!APPLE_SERVICE_ID) { showToast('Apple Sign In nicht konfiguriert'); return; } 4246 try { 4247 const resp = await AppleID.auth.signIn(); 4248 const token = resp?.authorization?.id_token; 4249 if (!token) { showToast('Apple-Anmeldung abgebrochen'); return; } 4250 const first = resp?.user?.name?.firstName || ''; 4251 const last = resp?.user?.name?.lastName || ''; 4252 const d = await oauthPost({ action: 'apple', identity_token: token, first_name: first, last_name: last }); 4253 if (d.ok) { handleOAuthSuccess(d); } 4254 else if (d.action === 'link_required') { showLinkBox(d); } 4255 else { showToast(d.error || 'Apple-Anmeldung fehlgeschlagen'); } 4256 } catch(e) { 4257 const code = e?.error || (typeof e === 'string' ? e : ''); 4258 if (code === 'popup_closed_by_user') return; 4259 // Häufige Fehler-Codes von Apple erklären 4260 const msgs = { 4261 'invalid_client': 'Service ID ungültig â Developer Portal prüfen', 4262 'invalid_request': 'Return URL nicht registriert â Developer Portal prüfen', 4263 'invalid_grant': 'Apple-Token abgelaufen â erneut versuchen', 4264 'user_cancelled_authorize': 'Abgebrochen', 4265 }; 4266 showToast(msgs[code] || ('Apple-Fehler: ' + (code || JSON.stringify(e)))); 4267 console.error('Apple Sign In error:', e); 4268 } 4269}); 4270 4271// ââ Sign in with Google (Redirect-Flow, mobilfreundlich) âââââââââââââââââââââ 4272const btnGoogle = document.getElementById('btn-google-signin'); 4273if (btnGoogle) { 4274 btnGoogle.addEventListener('click', () => { 4275 if (!GOOGLE_CLIENT_ID) { showToast('Google Sign In nicht konfiguriert'); return; } 4276 window.location.href = '/login_google_start.php?pwa=1'; 4277 }); 4278} 4279 4280// Gas-Typ automatisch speichern (einmalig beim ersten OAuth-Login ohne gas) 4281// ââ Profil: Gas-Typ wählen âââââââââââââââââââââââââââââââââââââââââââââââââââ 4282// 21.08.2026 Bugfix (User-Report: "Umschalter der Darstellung funktioniert, zeigt aber immer
4283// kurz 'Falsche Gasart' an") - die Theme-Buttons (Hell/Dunkel/Auto) teilen sich aus Styling- 4284// Gruenden dieselbe CSS-Klasse .gas-pick-btn mit den LPG/CNG-Buttons, wurden dadurch aber auch 4285// von DIESEM Handler erfasst und loesten bei jedem Klick zusaetzlich ein sinnloses set_gas- 4286// Request ohne data-gas aus -> Server lehnt mit "Ungueltige Gasart" ab (oauth.php), kurz als 4287// Toast sichtbar. Der eigentliche Theme-Wechsel lief parallel ueber den separaten 4288// [data-theme-choice]-Handler korrekt durch. Selektor auf [data-gas] eingegrenzt, damit nur die 4289// echten Gas-Typ-Buttons erfasst werden. 4290document.querySelectorAll('.gas-pick-btn[data-gas]').forEach(btn => { 4291 btn.addEventListener('click', async () => { 4292 if (!currentUser) return; 4293 const gas = btn.dataset.gas; 4294 btn.disabled = true; 4295 try { 4296 const d = await oauthPost({ action: 'set_gas', gas }); 4297 if (d.ok) { 4298 currentUser.gas = gas; 4299 setMode(gas); 4300 localStorage.setItem('gasdb_gas_locked', gas); 4301 renderProfileView(); 4302 showToast((gas === 'lpg' ? 'â½ LPG' : 'ðµ CNG') + ' als Gas-Typ gespeichert'); 4303 } else { showToast(d.error || 'Fehler beim Speichern'); } 4304 } catch(e) { showToast('Verbindungsfehler'); } 4305 btn.disabled = false; 4306 }); 4307}); 4308 4309async function autoSaveGas(gas) { 4310 if (!gas || !currentUser) return; 4311 await oauthPost({ action: 'set_gas', gas }); 4312 currentUser.gas = gas; 4313 renderProfileView(); 4314} 4315 4316// ââ Preismeldungs-Sheet ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4317let priceSheetStation = null; 4318 4319function openPriceSheet(s) { 4320 priceSheetStation = s; 4321 const gas = currentMode === 'cng' ? 'cng' : 'lpg'; 4322 const price = gas === 'cng' ? s.cng : s.lpg; 4323 const stand = gas === 'cng' ? s.cng_stand : s.lpg_stand; 4324 4325 document.getElementById('ps-btn-confirm').textContent = 4326 `â ${price ? price.toFixed(3) + ' ' + priceUnit(s) : ''} bestätigen`; 4327 document.getElementById('ps-btn-confirm').disabled = !price; 4328 4329 // 04.09.2026: Preiszeile ist jetzt immer offen (kein "Preis aktualisieren"-Umschalt-Button 4330 // mehr, siehe HTML-Kommentar), nur die Nachrichten-Zeile wird beim (Wieder-)Ãffnen geschlossen. 4331 document.getElementById('ps-msg-row').classList.remove('open'); 4332 document.getElementById('ps-price-input').value = price ? price.toFixed(3) : ''; 4333 4334 // 04.09.2026 (User-Wunsch: "aktualisieren" bzw. "melden" in Abhängigkeit der EFFEKTIVEN 4335 // Sperre anzeigen, nicht generisch immer "Melden") - Submit-Button startet neutral, wird nach 4336 // der asynchronen Status-Abfrage (Admin/Selbstmelder/Premium-Versorger/-Betreiber dieser 4337 // Station umgehen die Sperre, siehe price_report.php action=status) korrekt beschriftet. 4338 const submitBtn = document.getElementById('ps-price-submit'); 4339 submitBtn.textContent = 'â¦'; 4340 fetch('api/price_report.php', { 4341 method: 'POST', 4342 body: new URLSearchParams({ action: 'status', tankid: s.id, gas }) 4343 }) 4344 .then(r => r.json()) 4345 .then(d => { 4346 if (priceSheetStation !== s) return; // Sheet inzwischen geschlossen/gewechselt 4347 const locked = d.ok && d.gesperrt_fuer_mich; 4348 submitBtn.textContent = locked ? 'Melden' : 'Aktualisieren'; 4349 // 29.09.2026: Bestaetigen unterliegt jetzt derselben Sperre wie Aktualisieren (Server-Gate in 4350 // price_report.php/preis_gesperrt_fuer()) - Button gleich deaktivieren statt erst abzulehnen. 4351 if (locked) { 4352 const cb = document.getElementById('ps-btn-confirm'); 4353 cb.disabled = true; 4354 cb.textContent = 'Bestätigen gesperrt'; 4355 } 4356 }) 4357 .catch(() => { if (priceSheetStation === s) submitBtn.textContent = 'Melden'; }); 4358 4359 document.getElementById('price-sheet-overlay').classList.add('open'); 4360} 4361 4362function closePriceSheet() { 4363 document.getElementById('price-sheet-overlay').classList.remove('open'); 4364 priceSheetStation = null; 4365} 4366 4367async function priceReport(action, extra = {}) { 4368 if (!priceSheetStation) return; 4369 // 04.09.2026 Bugfix: Station VOR closePriceSheet() lokal sichern - closePriceSheet() setzt 4370 // priceSheetStation auf null, der Erfolgs-Zweig unten griff bisher trotzdem noch darauf zu 4371 // (TypeError, unbehandelt in dieser async function - Toast + Detail-Neuzeichnen blieben nach 4372 // einem erfolgreichen Update/Bestaetigen stumm aus, nur in der Konsole sichtbar).
4373 const station = priceSheetStation; 4374 const gas = currentMode === 'cng' ? 'cng' : 'lpg'; 4375 const newPrice = extra.price ? parseFloat(String(extra.price).replace(',', '.')) : null; 4376 const body = new URLSearchParams({ action, tankid: station.id, gas, ...extra }); 4377 let data; 4378 try { 4379 const r = await fetch('api/price_report.php', { method: 'POST', body }); 4380 data = await r.json(); 4381 } catch(e) { 4382 closePriceSheet(); 4383 showToast('Fehler beim Senden', undefined, 'error'); 4384 return; 4385 } 4386 closePriceSheet(); 4387 if (data.ok) { 4388 // Optimistisches Update: Preis + Timestamp im lokalen Objekt sofort setzen 4389 if (action === 'update' && !data.queued && newPrice) { 4390 const standKey = gas === 'cng' ? 'cng_stand' : 'lpg_stand'; 4391 station[gas] = newPrice; 4392 station[standKey] = Math.floor(Date.now() / 1000); 4393 // Station auch in currentStations aktualisieren 4394 const idx = currentStations.findIndex(x => x.id === station.id); 4395 if (idx !== -1) { 4396 currentStations[idx][gas] = newPrice; 4397 currentStations[idx][standKey] = station[standKey]; 4398 } 4399 // Detail-View neu rendern 4400 showDetail(station); 4401 } else if (action === 'confirm') { 4402 const standKey = gas === 'cng' ? 'cng_stand' : 'lpg_stand'; 4403 station[standKey] = Math.floor(Date.now() / 1000); 4404 showDetail(station); 4405 } 4406 } 4407 // 05.09.2026 (User-Idee: "nach dem Erfolg und einem 'Danke!' wieder das grüne mit dem Button 4408 // (ohne Hilfe) zeigen, dann kann daraus ein neues Update gestartet werden") - im Quick-Jump- 4409 // Kontext (?q, siehe _quickJump weiter unten im selben Script) ersetzt nach jeder erfolgreichen 4410 // Aktion ein kurzes "Danke!" + das gruene Wiederholungs-Overlay den normalen Toast, damit direkt 4411 // die naechste Tankstelle drankommt - ein "Fenster schliessen" ist auf iOS ohnehin technisch 4412 // nicht moeglich (window.close() wirkt nur bei per Skript selbst geoeffneten Fenstern). 4413 if (_quickJump && data.ok) { 4414 showToast('Danke!', 1200, 'success'); 4415 setTimeout(showQuickJumpAgain, 1400); 4416 return; 4417 } 4418 showToast(data.msg || (data.error ? `Fehler: ${data.error}` : 'Fehler'), undefined, data.ok ? 'success' : 'error'); 4419} 4420 4421// Siehe Kommentar in priceReport() oben - zeigt das gruene q.php-Overlay (ohne Anleitung), ein 4422// Tap startet quickJumpToNearest() erneut fuer die naechste Tankstelle. 4423function showQuickJumpAgain() { 4424 document.getElementById('quickjump-again-overlay').hidden = false; 4425} 4426document.getElementById('qj-again-btn').addEventListener('click', () => { 4427 document.getElementById('quickjump-again-overlay').hidden = true; 4428 quickJumpToNearest(); 4429}); 4430 4431document.getElementById('ps-btn-confirm').addEventListener('click', () => priceReport('confirm')); 4432 4433// 04.09.2026 (User-Idee: "rechts daneben ein Quadrat gleicher Höhe mit Check-Haken als schnelle 4434// Preis-Bestätigung"): bestätigt den aktuellen Preis mit einem Tap, ohne das Preismeldungs-Sheet 4435// zu öffnen - nutzt dieselbe priceReport('confirm')-Logik wie der Sheet-Button. 4436function quickConfirmPrice(s) { 4437 priceSheetStation = s; 4438 priceReport('confirm'); 4439} 4440 4441// 04.09.2026 (User-Wunsch: "die Preiseingabe per Eingabe-Feld ist so wie es ist unterwegs nicht 4442// gut gemacht" - ein nacktes Zahlenfeld+Tastatur ist mit einer Hand/Handschuhen am Automaten 4443// unhandlich): +/- Stepper mit grossen Tap-Zielen, Long-Press beschleunigt automatisch. 4444(function initPriceStepper() { 4445 const PS_STEP = 0.01; 4446 const input = document.getElementById('ps-price-input'); 4447 4448 function adjust(delta) { 4449 const cur = parseFloat(String(input.value).replace(',', '.')) || 0; 4450 const next = Math.max(0, Math.round((cur + delta) * 1000) / 1000); 4451 input.value = next.toFixed(3); 4452 } 4453 4454 function bindStepper(btn, delta) { 4455 let delay = null, accel = null; 4456 const stop = () => { clearTimeout(delay); clearInterval(accel); delay = null; accel = null; }; 4457 btn.addEventListener('pointerdown', e => { 4458 e.preventDefault(); 4459 adjust(delta); 4460 delay = setTimeout(() => { accel = setInterval(() => adjust(delta), 90); }, 450); 4461 }); 4462 ['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => btn.addEventListener(ev, stop)); 4463 } 4464 4465 bindStepper(document.getElementById('ps-step-minus'), -PS_STEP); 4466 bindStepper(document.getElementById('ps-step-plus'), PS_STEP); 4467})(); 4468 4469document.getElementById('ps-price-submit').addEventListener('click', () => { 4470 const val = document.getElementById('ps-price-input').value.trim(); 4471 if (!val) return; 4472 priceReport('update', { price: val }); 4473}); 4474 4475document.getElementById('ps-btn-msg').addEventListener('click', () => {
4476 const row = document.getElementById('ps-msg-row'); 4477 const open = row.classList.toggle('open'); 4478 // 04.09.2026: Preiszeile blendet sich nur waehrend der Nachrichtenerfassung aus (Platzgrund im 4479 // Sheet) und kommt beim Schliessen der Nachrichtenzeile automatisch wieder - vorher blieb sie 4480 // nach einem Toggle dauerhaft ausgeblendet. 4481 document.getElementById('ps-price-input-row').classList.toggle('open', !open); 4482 if (open) document.getElementById('ps-msg-input').focus(); 4483}); 4484 4485document.getElementById('ps-msg-submit').addEventListener('click', () => { 4486 const val = document.getElementById('ps-msg-input').value.trim(); 4487 if (!val) return; 4488 priceReport('message', { message: val }); 4489}); 4490 4491document.getElementById('ps-btn-cancel').addEventListener('click', closePriceSheet); 4492document.getElementById('price-sheet-overlay').addEventListener('click', e => { 4493 if (e.target === document.getElementById('price-sheet-overlay')) closePriceSheet(); 4494}); 4495 4496// ââ Start ââ 4497renderFavoritesHome(); 4498 4499// 05.09.2026 (User-Idee: "vereinfachte PWA-URL um sofort auf die Detail-Ansicht der nächsten 4500// Tankstelle zu gelangen [...] fahre ich an einer Tankstelle vorbei und will nur kurz den 4501// angezeigten Preis aktualisieren", verkürzt auf "/mobile?q" statt Hash/eigenem Rewrite) - Flag 4502// VOR dem untenstehenden history.replaceState() sichern, das den Query-String synchron entfernt, 4503// bevor der finale showView('home')-Guard weiter unten ausgewertet wird. 4504// 05.09.2026: "let" statt "const" - der Bottom-Nav-Klick-Handler oben setzt das Flag beim 4505// bewussten Verlassen des Quick-Jump-Loops zurueck auf false (siehe dortiger Kommentar). 4506let _quickJump = new URLSearchParams(window.location.search).has('q'); 4507 4508// Apple Redirect-Flow: Hash auswerten (#oauth_token / #oauth_link / #oauth_error) 4509(async () => { 4510 const hash = window.location.hash; 4511 if (hash.startsWith('#oauth_token=')) { 4512 // Token einlösen â Session auf diesem Server setzen 4513 const params = new URLSearchParams(hash.slice(1)); 4514 const token = params.get('oauth_token') || ''; 4515 const name = decodeURIComponent(params.get('name') || ''); 4516 history.replaceState(null, '', '/mobile/'); 4517 if (token) { 4518 try { 4519 const fd = new FormData(); 4520 fd.append('action', 'redeem_token'); fd.append('token', token); 4521 const r = await fetch('/mobile/api/oauth.php', { method: 'POST', body: fd }); 4522 const d = await r.json(); 4523 if (d.ok) { 4524 currentUser = { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 }; 4525 renderProfileView(); 4526 showToast('Willkommen, ' + (d.vorname || d.user || name) + '!'); 4527 } else { showToast('Anmeldung fehlgeschlagen: ' + (d.error || '')); } 4528 } catch(e) { showToast('Verbindungsfehler beim Token-Einlösen'); } 4529 } 4530 } else if (hash.startsWith('#oauth_link=')) { 4531 await checkAuthStatus(); 4532 try { 4533 const data = JSON.parse(decodeURIComponent(hash.slice(12))); 4534 showLinkBox({ provider: data.provider, sub: data.sub, email: data.email, action: 'link_required' }); 4535 showView('profile'); 4536 } catch(e) {} 4537 history.replaceState(null, '', '/mobile/'); 4538 } else if (hash.startsWith('#oauth_error=')) { 4539 const code = hash.slice(13); 4540 await checkAuthStatus(); 4541 showToast('Apple-Fehler: ' + code); 4542 history.replaceState(null, '', '/mobile/'); 4543 } else if (_quickJump) { 4544 // /mobile?q - kuerzeste Variante, keine eigene Rewrite-Regel noetig: Apache leitet "/mobile" 4545 // ohnehin auf "/mobile/" um und behaelt dabei den Query-String bei ("/mobile/?q"). 4546 // 05.09.2026 Nachbesserung (User: Safari "verliert" das "?q", dadurch liess sich die Seite 4547 // nicht mit dem Kurz-Link zum Home-Bildschirm hinzufuegen) - KEIN history.replaceState() hier 4548 // (anders als bei den OAuth-Zweigen oben, wo ein sensibler Token aus der Adresszeile entfernt 4549 // werden MUSS): "Zum Home-Bildschirm hinzufuegen" uebernimmt die aktuell in der Adressleiste 4550 // stehende URL - ein sofortiges Saeubern haette das "?q" schon vor dem manuellen Hinzufuegen 4551 // wieder entfernt. 4552 await checkAuthStatus(); 4553 quickJumpToNearest(); 4554 } else { 4555 await checkAuthStatus(); 4556 } 4557})(); 4558 4559// ?q wechselt selbst in die Detailansicht, sobald der Standort vorliegt - kein Zwischenblitz auf 4560// 'home', der Loader bleibt bis dahin sichtbar (quickJumpToNearest() steuert ihn selbst). 4561if (!_quickJump) { 4562 showView('home'); 4563 showLoader(false); 4564} 4565</script>
4565 4566</body> 4567</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.