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

@ -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,
};