Offline-Karten Runde 3: Puls-Icon, rollendes Vorausladen, Ausschnitt-Download, Speicher-Cap

- Offline-Indikator: pulsierendes 32px-Icon oben rechts (unter Kopfzeilen-Hoehe)
  statt Leiste ueber die volle Breite — verdeckte '<- Zurueck' in der
  Routennavigation (Geraetetest Rene)
- Rollendes Vorausladen: setGps laedt alle ~400m still fehlende z14+-2-Kacheln
  um die Position — deckt den Weg schon beim ERSTEN Funkloch-Besuch ab
- Bereichsauswahl light: 'Sichtbaren Ausschnitt speichern' im Offline-Modal
  (downloadBbox, Cap 40 MB, Zu-gross-Schutz)
- Speicher-Cap 250 MB als Soft-Guard fuer automatische Pfade + totalBytes-Zaehler
  + navigator.storage.persist() best-effort; echte LRU vertagt (Refcounting noetig)
- Auto-OSM-Raster-Prefetch entfernt (manueller Leaflet-Pfad bleibt)
- Logik-Tests (Node-Stubs) fuer Bbox/Cap/Throttle/persist bestanden
Bump v1229
This commit is contained in:
rene 2026-06-06 12:34:48 +02:00
parent 3426d2b7c8
commit 763108fa7c
10 changed files with 214 additions and 40 deletions

View file

@ -1 +1 @@
1228
1229

View file

@ -7307,12 +7307,27 @@ svg.empty-state-icon {
pointer-events: none;
letter-spacing: 0.01em;
}
/* Eingeklappt (5s nach Offline-Gang, boot.js): schmale Icon-Leiste statt 2-Zeilen-Banner
das volle Banner verdeckte die Karten-Steuerung oben (Gerätetest iOS 2026-06-06). */
/* Eingeklappt (5s nach Offline-Gang, boot.js): kleines pulsierendes Icon oben rechts
die Leiste über die volle Breite verdeckte Nav-Elemente (z.B. Zurück" in der
Routennavigation, Gerätetest 2026-06-07). Sitzt UNTERHALB der Kopfzeilen-Höhe,
damit es Buttons (Zentrieren, Legende) nie überlagert. */
#offline-banner.collapsed {
padding: calc(env(safe-area-inset-top, 0px) + 2px) 16px 2px;
top: calc(env(safe-area-inset-top, 0px) + 54px);
left: auto;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
box-shadow: 0 2px 10px rgba(0,0,0,.35);
animation: by-offline-pulse 2s ease-in-out infinite;
}
#offline-banner.collapsed #offline-banner-text { display: none; }
#offline-banner.collapsed #offline-queue-badge { display: none !important; }
@keyframes by-offline-pulse {
0%, 100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
#offline-banner.collapsed #offline-banner-text { display: none; }
/* ------------------------------------------------------------
STREAK-WIDGET (Welcome-Seite)

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1228"></script>
<script src="/js/boot-early.js?v=1229"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1228">
<link rel="stylesheet" href="/css/layout.css?v=1228">
<link rel="stylesheet" href="/css/components.css?v=1228">
<link rel="stylesheet" href="/css/utilities.css?v=1228">
<link rel="stylesheet" href="/css/lists.css?v=1228">
<link rel="stylesheet" href="/css/design-system.css?v=1229">
<link rel="stylesheet" href="/css/layout.css?v=1229">
<link rel="stylesheet" href="/css/components.css?v=1229">
<link rel="stylesheet" href="/css/utilities.css?v=1229">
<link rel="stylesheet" href="/css/lists.css?v=1229">
</head>
<body>
@ -612,11 +612,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1228"></script>
<script src="/js/ui.js?v=1228"></script>
<script src="/js/app.js?v=1228"></script>
<script src="/js/worlds.js?v=1228"></script>
<script src="/js/offline-indicator.js?v=1228"></script>
<script src="/js/api.js?v=1229"></script>
<script src="/js/ui.js?v=1229"></script>
<script src="/js/app.js?v=1229"></script>
<script src="/js/worlds.js?v=1229"></script>
<script src="/js/offline-indicator.js?v=1229"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -626,7 +626,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1228"></script>
<script src="/js/boot.js?v=1229"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1228'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1229'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -234,6 +234,7 @@ window.MapOffline = (function () {
function downloadAround(lat, lon, opts) {
if (typeof opts === 'number') opts = {}; // alte Signatur (lat, lon, radiusKm) → Default-Budget
opts = opts || {};
_persistStorage();
var budget = (opts.budgetMB || 5) * 1048576;
var maxKm = opts.maxRadiusKm || 25;
var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM);
@ -293,6 +294,7 @@ window.MapOffline = (function () {
return _addRegion({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: state.stored,
bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
})
.then(function () { return _bumpTotal(state.bytes); })
.then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, radiusKm: radiusKm }; });
}
@ -301,7 +303,73 @@ window.MapOffline = (function () {
// wird nie hochgeladen. Signal = echte Tile-Fetch-Fehler bei aktivem GPS
// (NICHT navigator.onLine — das lügt bei Captive-Portal/Schwachempfang).
var _gps = null, _lastZoneNote = 0;
function setGps(pos) { _gps = pos; } // {lat,lon} während aktiver Aufzeichnung, sonst null
// ---- Speicher-Cap (Soft-Guard für die AUTOMATISCHEN Pfade) -------------------
// Manuelle Downloads bleiben immer möglich; Vorausladen + Funkloch-Autofill stoppen
// über dem Cap. totalBytes wird bei jedem Download mitgezählt; clear() setzt zurück.
var CAP_MB = 250;
function _bumpTotal(bytes) {
if (!bytes) return Promise.resolve();
return _metaGet('totalBytes')
.then(function (t) { return _metaPut('totalBytes', (t || 0) + bytes); })
.catch(function () {});
}
function _overCap() {
return _metaGet('totalBytes')
.then(function (t) { return (t || 0) > CAP_MB * 1048576; })
.catch(function () { return false; });
}
// Persistenten Speicher anfragen (best-effort, idempotent) — härtet IndexedDB gegen
// Eviction bei Speicherdruck. Safari/iOS ignoriert es teils, schadet aber nicht.
function _persistStorage() {
if (_persistStorage._done) return;
_persistStorage._done = true;
try {
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(function () {});
} catch (e) {}
}
// {lat,lon} während aktiver Aufzeichnung, sonst null. Nebeneffekt (Runde 3):
// ROLLENDES VORAUSLADEN — solange Empfang da ist, alle ~400 m die fehlenden Kacheln
// um die aktuelle Position still mitnehmen. Deckt den Weg + die Anfahrt ab, BEVOR
// man ins Funkloch läuft (greift schon beim ersten Besuch, anders als das Gedächtnis).
var _lastPre = null, _preActive = false;
function setGps(pos) {
_gps = pos;
if (!pos) { _lastPre = null; return; }
if (_preActive || !navigator.onLine) return;
if (_lastPre && _distKm(_lastPre.lat, _lastPre.lon, pos.lat, pos.lon) < 0.4) return;
_preActive = true;
var p = { lat: pos.lat, lon: pos.lon };
_overCap().then(function (over) {
if (over) return;
return _prefetchRing(p.lat, p.lon, 2).then(function () { _lastPre = p; });
}).catch(function () {})
.then(function () { _preActive = false; });
}
// z14-Kacheln ±n um lat/lon (+ Eltern z1013) — NUR fehlende, still, ohne Region-Eintrag.
function _prefetchRing(lat, lon, n) {
var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM), seen = {}, list = [];
for (var x = cx - n; x <= cx + n; x++) for (var y = cy - n; y <= cy + n; y++) {
list.push([MAXZOOM, x, y]);
for (var pz = 13; pz >= 10; pz--) {
var px = x >> (MAXZOOM - pz), py = y >> (MAXZOOM - pz), k = pz + '/' + px + '/' + py;
if (!seen[k]) { seen[k] = 1; list.push([pz, px, py]); }
}
}
var missing = [], chain = Promise.resolve();
list.forEach(function (t) {
chain = chain.then(function () {
return _get(t[0] + '/' + t[1] + '/' + t[2]).then(function (hit) { if (!hit) missing.push(t); });
});
});
var state = { done: 0, bytes: 0, stored: 0 };
return chain
.then(function () { return missing.length ? _fetchTiles(missing, state, null) : null; })
.then(function () { return _bumpTotal(state.bytes); });
}
function _distKm(aLat, aLon, bLat, bLon) {
var dLat = (bLat - aLat) * 111, dLon = (bLon - aLon) * 111 * Math.cos(aLat * Math.PI / 180);
@ -335,7 +403,11 @@ window.MapOffline = (function () {
if (_autofillActive || !navigator.onLine) return Promise.resolve(0);
_autofillActive = true;
var filled = 0;
return _metaGet('deadzones').then(function (zones) {
return _overCap().then(function (over) {
if (over) return null; // Speicher-Cap erreicht → kein automatisches Nachladen mehr
return _metaGet('deadzones');
}).then(function (zones) {
if (zones === null) return 0;
zones = zones || [];
var open = zones.filter(function (z) { return !z.filled; });
if (!open.length) return 0;
@ -360,6 +432,7 @@ window.MapOffline = (function () {
function downloadCorridor(track, opts) {
opts = opts || {};
if (!track || track.length < 2) return Promise.reject(new Error('Kein GPS-Track'));
_persistStorage();
var buffer = opts.bufferKm || 1, cap = (opts.capMB || 50) * 1048576;
var seen = {}, list = [];
var push = function (z, x, y) {
@ -403,6 +476,47 @@ window.MapOffline = (function () {
return _addRegion({ type: 'korridor', name: opts.name || null, lat: track[0].lat, lon: track[0].lon,
tiles: state.stored, bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
})
.then(function () { return _bumpTotal(state.bytes); })
.then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; });
}
// ---- Bereichsauswahl: sichtbaren Karten-Ausschnitt komplett speichern ---------
// bbox = {south,west,north,east} (z.B. aktueller Viewport). Zu-groß-Schutz über
// Kachelzahl, Abbruch-Cap über capMB. opts {capMB:40, name, onProgress({bytes,done,total})}.
function downloadBbox(bbox, opts) {
opts = opts || {};
_persistStorage();
var cap = (opts.capMB || 40) * 1048576;
var seen = {}, list = [];
var push = function (z, x, y) {
if (x < 0 || y < 0 || x >= Math.pow(2, z) || y >= Math.pow(2, z)) return;
var k = z + '/' + x + '/' + y;
if (!seen[k]) { seen[k] = 1; list.push([z, x, y]); }
};
for (var z = 0; z <= MAXZOOM; z++) {
var x0 = _x(bbox.west, z), x1 = _x(bbox.east, z), y0 = _y(bbox.north, z), y1 = _y(bbox.south, z);
if (z === MAXZOOM && (x1 - x0 + 1) * (y1 - y0 + 1) > 4000) {
return Promise.reject(new Error('Bereich zu groß — bitte weiter reinzoomen.'));
}
for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) push(z, x, y);
}
var state = { done: 0, bytes: 0, stored: 0 }, total = list.length, poiCount = 0;
function chunkLoop(idx) {
if (idx >= list.length || state.bytes >= cap) return Promise.resolve();
return _fetchTiles(list.slice(idx, idx + 64), state, function () {
if (opts.onProgress) opts.onProgress({ bytes: state.bytes, done: state.done, total: total });
}).then(function () { return chunkLoop(idx + 64); });
}
var midLat = (bbox.south + bbox.north) / 2, midLon = (bbox.west + bbox.east) / 2;
return chunkLoop(0)
.then(function () { return _cacheGlyphs(); })
.then(function (gb) { state.bytes += gb; return _cachePois(bbox); })
.then(function (pc) {
poiCount = pc;
return _addRegion({ type: 'ausschnitt', name: opts.name || null, lat: midLat, lon: midLon,
tiles: state.stored, bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
})
.then(function () { return _bumpTotal(state.bytes); })
.then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; });
}
@ -432,8 +546,10 @@ window.MapOffline = (function () {
function stats() {
return _count().then(function (count) {
return _metaGet('regions').then(function (regions) {
return _metaGet('region').then(function (meta) {
return { count: count, meta: meta || null, regions: regions || [] };
return _metaGet('totalBytes').then(function (totalBytes) {
return _metaGet('region').then(function (meta) {
return { count: count, meta: meta || null, regions: regions || [], totalBytes: totalBytes || 0 };
});
});
});
});
@ -446,7 +562,7 @@ window.MapOffline = (function () {
return {
registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor,
tile: tile, glyph: glyph, pois: pois, alerts: alerts, coverage: coverage,
downloadBbox: downloadBbox, tile: tile, glyph: glyph, pois: pois, alerts: alerts, coverage: coverage,
setGps: setGps, markDeadZone: markDeadZone, autoFillDeadZones: autoFillDeadZones,
stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM,
};

View file

@ -417,9 +417,9 @@ window.OfflineIndicator = (() => {
function init() {
refresh();
_prefetchPages();
// OSM-Raster-Prefetch nur für die Leaflet-Karte — die GL-Karte (byt://-Vektorkacheln)
// nutzt das Raster nicht. Komplett-Entfernung wenn Flag dauerhaft AN (OFFLINE_MAPS_PLAN.md).
if (!_offlineTilesMode()) _prefetchTiles();
// Automatischer OSM-Raster-Prefetch ENTFERNT (2026-06-07): Flag ist auf allen deployten
// Hosts AN, die GL-Karte nutzt das Raster nicht. _prefetchTiles bleibt nur noch für den
// manuellen „Fehlende nachladen"-Pfad im Leaflet-Modus (localhost / by_map_gl=0).
_prefetchData();
_bindLongPress();

View file

@ -2190,6 +2190,34 @@ window.Page_map = (() => {
}
}
// Bereichsauswahl: den SICHTBAREN Karten-Ausschnitt komplett speichern (z.B. fürs
// Urlaubsziel: hinzoomen/-schieben, speichern). Cap 40 MB, Zu-groß-Schutz in MapOffline.
async function _downloadViewport() {
if (!_map || !window.MapOffline) return;
const btn = document.getElementById('map-offline-btn');
if (btn?.classList.contains('loading')) return;
const p = _mapPaddedBounds(0.02);
btn?.classList.add('loading');
_setOsmStatus('Offline: 0 MB…');
try {
const res = await MapOffline.downloadBbox(
{ south: p.south, west: p.west, north: p.north, east: p.east },
{ capMB: 40, onProgress: pr => {
_setOsmStatus(`Offline: ${(pr.bytes / 1048576).toFixed(1)} MB (${Math.round(pr.done / pr.total * 100)} %)…`);
} });
_setOsmStatus('');
UI.toast.success(`Ausschnitt offline gespeichert — ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.`
+ `${res.capped ? ' (40-MB-Limit erreicht)' : ''}`);
window.OfflineIndicator?.refresh();
if (_covOn) _setCoverage(true);
} catch (e) {
_setOsmStatus('');
UI.toast.error(e?.message?.includes('zu groß') ? e.message : 'Offline-Download fehlgeschlagen — bitte erneut versuchen.');
} finally {
btn?.classList.remove('loading');
}
}
// ----------------------------------------------------------
// Offline-Bereiche-Layer (gespeicherte z14-Kacheln) + Verwaltungs-Modal
// ----------------------------------------------------------
@ -2226,7 +2254,7 @@ window.Page_map = (() => {
let s = { regions: [] };
try { s = await MapOffline.stats(); } catch (e) {}
const regions = s.regions || [];
const totalBytes = regions.reduce((a, r) => a + (r.bytes || 0), 0);
const totalBytes = s.totalBytes || regions.reduce((a, r) => a + (r.bytes || 0), 0);
const totalPois = regions.reduce((a, r) => a + (r.pois || 0), 0);
UI.modal.open({
title: '🗺️ Offline-Karten',
@ -2238,6 +2266,7 @@ window.Page_map = (() => {
</p>
<div class="flex flex-col gap-2">
<button class="btn btn-primary" id="off-dl">${UI.icon('download-simple')} Dieses Gebiet speichern (~5 MB)</button>
<button class="btn btn-secondary" id="off-bbox">${UI.icon('squares-four')} Sichtbaren Ausschnitt speichern</button>
<button class="btn btn-secondary" id="off-cov">${UI.icon('stack')} Gespeicherte Bereiche ${_covOn ? 'ausblenden' : 'anzeigen'}</button>
${regions.length ? `<button class="btn btn-secondary" id="off-clear" style="color:var(--c-danger)">${UI.icon('trash')} Alles löschen</button>` : ''}
</div>
@ -2245,6 +2274,7 @@ window.Page_map = (() => {
footer: `<button class="btn btn-secondary" data-modal-close style="width:100%">Schließen</button>`,
});
document.getElementById('off-dl')?.addEventListener('click', () => { UI.modal.close(); _downloadVectorRegion(); });
document.getElementById('off-bbox')?.addEventListener('click', () => { UI.modal.close(); _downloadViewport(); });
document.getElementById('off-cov')?.addEventListener('click', async () => { UI.modal.close(); await _setCoverage(!_covOn); });
document.getElementById('off-clear')?.addEventListener('click', async e => {
const btn = e.currentTarget;

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1228"></script>
<script src="/js/landing-init.js?v=1229"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1228';
const VER = '1229';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -67,17 +67,30 @@ nach bestandenen Gerätetests Runde 1+2). localhost = Leaflet/AUS.
bestanden) — er lag im bereits gespeicherten Gebiet. Nach dem Speichern werden die
gespeicherten Bereiche jetzt blau auf der Routen-Detailkarte eingeblendet (`_detailMap._gl`).
**🔲 Offen (Runde 3):**
- **Gerätetest Runde 2** (Budget-Download, Funkloch-Lernen auf echter Gassi-Runde, Korridor,
Coverage-Layer) → dann Prod-Freigabe-Entscheidung (BY.offlineTiles-Default erweitern analog `by_map_gl`).
- **Rollendes Vorausladen beim Aufzeichnen** (fortlaufend um die aktuelle Position cachen, solange
Empfang da — deckt den Weg schon beim ersten Mal ab; Akku-/Datensparsamkeit beachten).
- **Bereichsauswahl** (Karten-Ausschnitt/Rechteck als Download-Gebiet) — Korridor deckt den
Hauptfall ab, Rest nach Bedarf.
- **Speicher-Cap + LRU** über alles (alte Gebiete fliegen automatisch raus) + optional
`navigator.storage.persist()`.
- Alten OSM-Raster-Prefetch (`offline-indicator.js _prefetchTiles` + `map.js _cacheTiles`) komplett
entfernen, wenn Flag dauerhaft AN (auch Prod).
**✅ Runde 3 (2026-06-07):**
- **Offline-Indikator = pulsierendes Icon** oben rechts (32 px, unterhalb der Kopfzeilen-Höhe) statt
Banner über die volle Breite — verdeckte Nav-Elemente, z.B. „← Zurück" in der Routennavigation
(Gerätetest René). Vollbanner weiterhin 5 s beim Offline-Gang, dann Icon.
- **Rollendes Vorausladen beim Aufzeichnen:** `setGps()` lädt alle ~400 m still die FEHLENDEN
z14±2-Kacheln (+Eltern) um die Position, solange online — deckt Weg + Anfahrt schon beim ERSTEN
Besuch ab (das Funkloch-Gedächtnis greift erst ab dem 2.). Kein Region-Eintrag, kein UI.
- **Bereichsauswahl light:** Modal-Button „Sichtbaren Ausschnitt speichern" → `downloadBbox(viewport,
{capMB:40})` mit Zu-groß-Schutz (>4000 z14-Kacheln → „bitte reinzoomen").
- **Speicher-Cap 250 MB (Soft-Guard):** `totalBytes`-Zähler in Meta; AUTOMATISCHE Pfade (Vorausladen,
Funkloch-Autofill) stoppen über dem Cap, manuelle bleiben; `navigator.storage.persist()` best-effort.
Echte LRU-Eviction bewusst vertagt (Kacheln werden regionsübergreifend geteilt → Eviction braucht
Refcounting; bei ~8 MB/Gebiet kein Druck).
- **Auto-OSM-Raster-Prefetch entfernt** (offline-indicator init); `_prefetchTiles`/`_cacheTiles`
bleiben nur für den manuellen Leaflet-Pfad (localhost / by_map_gl=0).
- Logik per Node-Stub-Tests verifiziert (Bbox, Zu-groß, Cap, Prefetch-Throttle, persist).
Achtung Node 21+: eingebautes `navigator`-Global schluckt `global.navigator=`-Stubs —
`Object.defineProperty(globalThis, 'navigator', …)` verwenden.
**🔲 Offen (Backlog):**
- Echte LRU-Eviction (Refcounting/Region-Zuordnung der Kacheln), wenn Nutzer real ans Cap kommen.
- Rechteck-Zeichnen als präzisere Bereichsauswahl (Viewport-Variante deckt den Hauptfall ab).
- POIs auch beim rollenden Vorausladen (aktuell nur Kacheln; Giftköder kommen aus dem
localStorage-Fallback der letzten Online-Position).
## Ziel
GL-Vektorkarten offline-tauglich machen — Kernszenario **Gassi/Wandern im Funkloch**.