Offline-Karten Runde 2: adaptives Modell (Budget, Funkloch-Gedaechtnis, Korridor, Coverage)
Design Rene 2026-06-06: - Budget-Download: z14-Ringe um den Standort bis 5 MB gespeicherte Bytes (Stadt klein, Land gross — passend zur Funknetzdichte); client-seitig, Server-Region-Extract entfaellt - Funkloch-Gedaechtnis: Tile-Miss bei aktivem GPS-Recording -> Zone gemerkt (lokal, nie hochgeladen); Auto-Download offener Zonen sobald online - Routen-Korridor: 'Offline'-Button im Routen-Detail, Kacheln +-1km um den Track + Marker (Cap 50 MB) — fuer mehrtaegige Unternehmungen - Coverage-Layer: gespeicherte Bereiche als blauer Layer; Offline-Button oeffnet Verwaltungs-Modal (Stats, speichern, anzeigen, loeschen) - Flag-Logik zentral in boot.js BY.offlineTiles() (war 3x dupliziert) Bump v1226
This commit is contained in:
parent
45534aa8ee
commit
42a04ec405
12 changed files with 466 additions and 91 deletions
|
|
@ -38,6 +38,8 @@ window.MapOffline = (function () {
|
|||
var _get = function (k) { return _req(STORE, 'readonly', function (os) { return os.get(k); }); };
|
||||
var _put = function (k, v) { return _req(STORE, 'readwrite', function (os) { os.put(v, k); }); };
|
||||
var _count = function () { return _req(STORE, 'readonly', function (os) { return os.count(); }); };
|
||||
var _metaGet = function (k) { return _req(META, 'readonly', function (os) { return os.get(k); }); };
|
||||
var _metaPut = function (k, v) { return _req(META, 'readwrite', function (os) { os.put(v, k); }); };
|
||||
|
||||
// ---- Remote-PMTiles ----
|
||||
function _pmInst() { if (!_pm) _pm = new pmtiles.PMTiles(MapGLStyle.tilesUrl()); return _pm; }
|
||||
|
|
@ -48,7 +50,10 @@ window.MapOffline = (function () {
|
|||
if (hit) return hit instanceof Uint8Array ? hit : new Uint8Array(hit);
|
||||
return _pmInst().getZxy(z, x, y).then(function (r) {
|
||||
return (r && r.data) ? new Uint8Array(r.data) : null; // getZxy ist bereits dekomprimiert
|
||||
}).catch(function () { return null; }); // offline + nicht gespeichert → leeres Tile
|
||||
}).catch(function () {
|
||||
_noteRemoteMiss(); // Funkloch-Signal: echter Fetch-Fehler bei aktivem GPS
|
||||
return null; // offline + nicht gespeichert → leeres Tile
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -81,14 +86,6 @@ window.MapOffline = (function () {
|
|||
var dLat = radiusKm / 111, dLon = radiusKm / (111 * Math.cos(lat * Math.PI / 180));
|
||||
return { south: lat - dLat, west: lon - dLon, north: lat + dLat, east: lon + dLon };
|
||||
}
|
||||
function _tileList(lat, lon, radiusKm) {
|
||||
var b = _bboxAround(lat, lon, radiusKm), list = [];
|
||||
for (var z = 0; z <= MAXZOOM; z++) {
|
||||
var x0 = _x(b.west, z), x1 = _x(b.east, z), y0 = _y(b.north, z), y1 = _y(b.south, z);
|
||||
for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) list.push([z, x, y]);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// Glyphs (Open Sans Regular/Semibold, Latin + Latin-Extended) in IndexedDB persistieren
|
||||
// (Key-Präfix 'f/' im Tiles-Store, kein Schema-Bump) — überlebt App-Updates, anders als der
|
||||
|
|
@ -165,37 +162,248 @@ window.MapOffline = (function () {
|
|||
}).catch(function () { return []; });
|
||||
}
|
||||
|
||||
// Bereich um lat/lon (radiusKm, Default 5) herunterladen + in IndexedDB ablegen.
|
||||
// onProgress({done,total,bytes}). Liefert {tiles,bytes,pois}.
|
||||
function downloadAround(lat, lon, radiusKm, onProgress) {
|
||||
radiusKm = radiusKm || 5;
|
||||
var list = _tileList(lat, lon, radiusKm), total = list.length, done = 0, bytes = 0, stored = 0, i = 0, CONC = 6;
|
||||
var poiCount = 0;
|
||||
// Kachelbreite (km) auf Zoom z bei lat — für Ring↔Radius-Umrechnung.
|
||||
function _tileKm(z, lat) { return 40075 * Math.cos(lat * Math.PI / 180) / Math.pow(2, z); }
|
||||
|
||||
// Kachel-Liste laden (Concurrency 6) — addiert auf state {done,bytes,stored}, ruft onTick alle 8 Tiles.
|
||||
function _fetchTiles(list, state, onTick) {
|
||||
var i = 0, CONC = 6;
|
||||
function next() {
|
||||
if (i >= total) return Promise.resolve();
|
||||
if (i >= list.length) return Promise.resolve();
|
||||
var t = list[i++], key = t[0] + '/' + t[1] + '/' + t[2];
|
||||
return _pmInst().getZxy(t[0], t[1], t[2]).then(function (r) {
|
||||
if (r && r.data) { var u = new Uint8Array(r.data); bytes += u.byteLength; stored++; return _put(key, u); }
|
||||
if (r && r.data) { var u = new Uint8Array(r.data); state.bytes += u.byteLength; state.stored++; return _put(key, u); }
|
||||
}).catch(function () {}).then(function () {
|
||||
done++;
|
||||
if (onProgress && (done % 8 === 0 || done === total)) onProgress({ done: done, total: total, bytes: bytes });
|
||||
state.done++;
|
||||
if (onTick && state.done % 8 === 0) onTick();
|
||||
return next();
|
||||
});
|
||||
}
|
||||
var w = []; for (var k = 0; k < CONC; k++) w.push(next());
|
||||
return Promise.all(w)
|
||||
.then(function () { return _cacheGlyphs(); }) // Glyphs mitcachen (sonst offline kein Render)
|
||||
.then(function (gb) { bytes += gb; return _cachePois(_bboxAround(lat, lon, radiusKm)); })
|
||||
.then(function (pc) { poiCount = pc; return _req(META, 'readwrite', function (os) {
|
||||
os.put({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: stored, bytes: bytes, pois: poiCount, savedAt: Date.now() }, 'region');
|
||||
}); })
|
||||
.then(function () { return { tiles: stored, bytes: bytes, pois: poiCount }; });
|
||||
return Promise.all(w);
|
||||
}
|
||||
|
||||
// Gespeichertes Gebiet in der Regions-Liste verbuchen ('region' = letztes, Back-Compat).
|
||||
function _addRegion(region) {
|
||||
return _metaGet('regions').then(function (list) {
|
||||
list = list || [];
|
||||
list.push(region);
|
||||
if (list.length > 30) list = list.slice(-30);
|
||||
return _metaPut('regions', list);
|
||||
}).then(function () { return _metaPut('region', region); });
|
||||
}
|
||||
|
||||
// Gebiet um lat/lon BUDGET-getrieben laden (Modell René 2026-06-07): z14-Ringe um den
|
||||
// Standort expandieren (+ Eltern-Kacheln z10–13), bis budgetMB GESPEICHERTE Bytes erreicht
|
||||
// sind — deckt in der Stadt einen kleineren, auf dem Land einen größeren Bereich ab
|
||||
// (dort sind die Funklöcher). Basis-Zooms 0–9 sind immer dabei (winzig).
|
||||
// opts {budgetMB:5, maxRadiusKm:25, onProgress({bytes,budget,done,radiusKm})}.
|
||||
// Liefert {tiles, bytes, pois, radiusKm}.
|
||||
function downloadAround(lat, lon, opts) {
|
||||
if (typeof opts === 'number') opts = {}; // alte Signatur (lat, lon, radiusKm) → Default-Budget
|
||||
opts = opts || {};
|
||||
var budget = (opts.budgetMB || 5) * 1048576;
|
||||
var maxKm = opts.maxRadiusKm || 25;
|
||||
var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM);
|
||||
var kmPerTile = _tileKm(MAXZOOM, lat);
|
||||
var maxRing = Math.max(1, Math.ceil(maxKm / kmPerTile));
|
||||
var seen = {}, state = { done: 0, bytes: 0, stored: 0 };
|
||||
var tick = function (radiusKm) {
|
||||
if (opts.onProgress) opts.onProgress({ bytes: state.bytes, budget: budget, done: state.done, radiusKm: radiusKm });
|
||||
};
|
||||
|
||||
// Basis-Zooms 0–9 über die maximale Bbox.
|
||||
var base = [], bb = _bboxAround(lat, lon, maxKm);
|
||||
for (var z = 0; z <= 9; z++) {
|
||||
var x0 = _x(bb.west, z), x1 = _x(bb.east, z), y0 = _y(bb.north, z), y1 = _y(bb.south, z);
|
||||
for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) {
|
||||
var k = z + '/' + x + '/' + y;
|
||||
if (!seen[k]) { seen[k] = 1; base.push([z, x, y]); }
|
||||
}
|
||||
}
|
||||
|
||||
// Ring r um die Zentrums-Kachel (z14) + zugehörige Eltern z10–13.
|
||||
function ringTiles(r) {
|
||||
var list = [];
|
||||
var push = function (z2, x2, y2) {
|
||||
if (x2 < 0 || y2 < 0 || x2 >= Math.pow(2, z2) || y2 >= Math.pow(2, z2)) return;
|
||||
var k2 = z2 + '/' + x2 + '/' + y2;
|
||||
if (!seen[k2]) { seen[k2] = 1; list.push([z2, x2, y2]); }
|
||||
};
|
||||
for (var x2 = cx - r; x2 <= cx + r; x2++) for (var y2 = cy - r; y2 <= cy + r; y2++) {
|
||||
if (Math.max(Math.abs(x2 - cx), Math.abs(y2 - cy)) !== r) continue;
|
||||
push(MAXZOOM, x2, y2);
|
||||
for (var pz = 13; pz >= 10; pz--) push(pz, x2 >> (MAXZOOM - pz), y2 >> (MAXZOOM - pz));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
var coveredRing = 0;
|
||||
function nextRing(r) {
|
||||
if (r > maxRing || state.bytes >= budget) return Promise.resolve();
|
||||
return _fetchTiles(ringTiles(r), state, function () { tick(r * kmPerTile); }).then(function () {
|
||||
coveredRing = r;
|
||||
tick(r * kmPerTile);
|
||||
return nextRing(r + 1);
|
||||
});
|
||||
}
|
||||
|
||||
var radiusKm = 0, poiCount = 0;
|
||||
return _fetchTiles(base, state, function () { tick(0); })
|
||||
.then(function () { return nextRing(0); })
|
||||
.then(function () {
|
||||
radiusKm = Math.max(1, Math.round(coveredRing * kmPerTile * 10) / 10);
|
||||
return _cacheGlyphs(); // Glyphs mitcachen (sonst offline kein Render)
|
||||
})
|
||||
.then(function (gb) { state.bytes += gb; return _cachePois(_bboxAround(lat, lon, radiusKm)); })
|
||||
.then(function (pc) {
|
||||
poiCount = pc;
|
||||
return _addRegion({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: state.stored,
|
||||
bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
|
||||
})
|
||||
.then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, radiusKm: radiusKm }; });
|
||||
}
|
||||
|
||||
// ---- Funkloch-Gedächtnis ----------------------------------------------------
|
||||
// „Wo verliere ich Netz" = Aufenthaltsorte → bleibt KOMPLETT LOKAL (IndexedDB),
|
||||
// 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
|
||||
|
||||
function _distKm(aLat, aLon, bLat, bLon) {
|
||||
var dLat = (bLat - aLat) * 111, dLon = (bLon - aLon) * 111 * Math.cos(aLat * Math.PI / 180);
|
||||
return Math.sqrt(dLat * dLat + dLon * dLon);
|
||||
}
|
||||
|
||||
function _noteRemoteMiss() {
|
||||
if (!_gps) return;
|
||||
var now = Date.now();
|
||||
if (now - _lastZoneNote < 120000) return; // max. 1 Eintrag / 2 Min
|
||||
_lastZoneNote = now;
|
||||
markDeadZone(_gps.lat, _gps.lon);
|
||||
}
|
||||
|
||||
// Funkloch merken (Dedupe: keine zweite Zone im Umkreis von 2 km, Cap 50).
|
||||
function markDeadZone(lat, lon) {
|
||||
return _metaGet('deadzones').then(function (zones) {
|
||||
zones = zones || [];
|
||||
if (zones.some(function (z) { return _distKm(z.lat, z.lon, lat, lon) < 2; })) return;
|
||||
zones.push({ lat: lat, lon: lon, ts: Date.now(), filled: false });
|
||||
if (zones.length > 50) zones = zones.slice(-50);
|
||||
return _metaPut('deadzones', zones);
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
// Offene Funkloch-Zonen budget-getrieben nachladen (nur online sinnvoll).
|
||||
// Dort wo Netz verfügbar ist, braucht man keine Offline-Karten — gespeichert
|
||||
// wird gezielt da, wo es ausfiel. Liefert die Anzahl gefüllter Zonen.
|
||||
var _autofillActive = false;
|
||||
function autoFillDeadZones() {
|
||||
if (_autofillActive || !navigator.onLine) return Promise.resolve(0);
|
||||
_autofillActive = true;
|
||||
var filled = 0;
|
||||
return _metaGet('deadzones').then(function (zones) {
|
||||
zones = zones || [];
|
||||
var open = zones.filter(function (z) { return !z.filled; });
|
||||
if (!open.length) return 0;
|
||||
var chain = Promise.resolve();
|
||||
open.forEach(function (z) {
|
||||
chain = chain.then(function () {
|
||||
return downloadAround(z.lat, z.lon, { budgetMB: 5 }).then(function (res) {
|
||||
if (res.bytes > 0) { z.filled = true; filled++; }
|
||||
}).catch(function () {});
|
||||
});
|
||||
});
|
||||
return chain.then(function () { return _metaPut('deadzones', zones); })
|
||||
.then(function () { return filled; });
|
||||
}).then(function (n) { _autofillActive = false; return n; },
|
||||
function () { _autofillActive = false; return 0; });
|
||||
}
|
||||
|
||||
// ---- Routen-Korridor ---------------------------------------------------------
|
||||
// Kacheln ±bufferKm um den Track (z14 + Eltern z10–13 + Basis 0–9) + Marker der
|
||||
// Korridor-Bbox — für mehrtägige Unternehmungen entlang einer Route.
|
||||
// track = [{lat,lon},...]; opts {bufferKm:1, capMB:50, name, onProgress({bytes,done,total})}.
|
||||
function downloadCorridor(track, opts) {
|
||||
opts = opts || {};
|
||||
if (!track || track.length < 2) return Promise.reject(new Error('Kein GPS-Track'));
|
||||
var buffer = opts.bufferKm || 1, cap = (opts.capMB || 50) * 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]); }
|
||||
};
|
||||
var s = 90, w = 180, n = -90, e = -180;
|
||||
track.forEach(function (p) {
|
||||
if (p.lat < s) s = p.lat; if (p.lat > n) n = p.lat;
|
||||
if (p.lon < w) w = p.lon; if (p.lon > e) e = p.lon;
|
||||
var d = Math.ceil(buffer / _tileKm(MAXZOOM, p.lat));
|
||||
var cx = _x(p.lon, MAXZOOM), cy = _y(p.lat, MAXZOOM);
|
||||
for (var x = cx - d; x <= cx + d; x++) for (var y = cy - d; y <= cy + d; y++) {
|
||||
push(MAXZOOM, x, y);
|
||||
for (var pz = 13; pz >= 10; pz--) push(pz, x >> (MAXZOOM - pz), y >> (MAXZOOM - pz));
|
||||
}
|
||||
});
|
||||
var latMid = (s + n) / 2;
|
||||
var bb = { south: s - buffer / 111, north: n + buffer / 111,
|
||||
west: w - buffer / (111 * Math.cos(latMid * Math.PI / 180)),
|
||||
east: e + buffer / (111 * Math.cos(latMid * Math.PI / 180)) };
|
||||
for (var z2 = 0; z2 <= 9; z2++) {
|
||||
var x0 = _x(bb.west, z2), x1 = _x(bb.east, z2), y0 = _y(bb.north, z2), y1 = _y(bb.south, z2);
|
||||
for (var x2 = x0; x2 <= x1; x2++) for (var y2 = y0; y2 <= y1; y2++) push(z2, x2, y2);
|
||||
}
|
||||
|
||||
var state = { done: 0, bytes: 0, stored: 0 }, total = list.length, poiCount = 0;
|
||||
// In 64er-Blöcken laden → zwischen den Blöcken greift der Speicher-Cap.
|
||||
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); });
|
||||
}
|
||||
return chunkLoop(0)
|
||||
.then(function () { return _cacheGlyphs(); })
|
||||
.then(function (gb) { state.bytes += gb; return _cachePois(bb); })
|
||||
.then(function (pc) {
|
||||
poiCount = pc;
|
||||
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 { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; });
|
||||
}
|
||||
|
||||
// ---- Coverage-Layer ----------------------------------------------------------
|
||||
// GeoJSON der gespeicherten z14-Kacheln — zeigt auf der Karte, welche Bereiche offline da sind.
|
||||
function _tile2lat(y, z) {
|
||||
var m = Math.PI - 2 * Math.PI * y / Math.pow(2, z);
|
||||
return 180 / Math.PI * Math.atan(0.5 * (Math.exp(m) - Math.exp(-m)));
|
||||
}
|
||||
function coverage() {
|
||||
var re = new RegExp('^' + MAXZOOM + '/(\\d+)/(\\d+)$');
|
||||
return _req(STORE, 'readonly', function (os) { return os.getAllKeys(); }).then(function (keys) {
|
||||
var feats = [];
|
||||
(keys || []).forEach(function (k) {
|
||||
var m = re.exec(k);
|
||||
if (!m) return;
|
||||
var x = +m[1], y = +m[2], n2 = Math.pow(2, MAXZOOM);
|
||||
var west = x / n2 * 360 - 180, east = (x + 1) / n2 * 360 - 180;
|
||||
var north = _tile2lat(y, MAXZOOM), south = _tile2lat(y + 1, MAXZOOM);
|
||||
feats.push({ type: 'Feature', properties: {}, geometry: { type: 'Polygon',
|
||||
coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] } });
|
||||
});
|
||||
return { type: 'FeatureCollection', features: feats };
|
||||
});
|
||||
}
|
||||
|
||||
function stats() {
|
||||
return _count().then(function (count) {
|
||||
return _req(META, 'readonly', function (os) { return os.get('region'); })
|
||||
.then(function (meta) { return { count: count, meta: meta || null }; });
|
||||
return _metaGet('regions').then(function (regions) {
|
||||
return _metaGet('region').then(function (meta) {
|
||||
return { count: count, meta: meta || null, regions: regions || [] };
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
function hasRegion() { return stats().then(function (s) { return s.count > 0; }).catch(function () { return false; }); }
|
||||
|
|
@ -205,7 +413,9 @@ window.MapOffline = (function () {
|
|||
}
|
||||
|
||||
return {
|
||||
registerProtocol: registerProtocol, downloadAround: downloadAround, tile: tile, glyph: glyph,
|
||||
pois: pois, stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM,
|
||||
registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor,
|
||||
tile: tile, glyph: glyph, pois: pois, coverage: coverage,
|
||||
setGps: setGps, markDeadZone: markDeadZone, autoFillDeadZones: autoFillDeadZones,
|
||||
stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue