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:
rene 2026-06-06 12:00:43 +02:00
parent 45534aa8ee
commit 42a04ec405
12 changed files with 466 additions and 91 deletions

View file

@ -1 +1 @@
1225
1226

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1225"></script>
<script src="/js/boot-early.js?v=1226"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1225">
<link rel="stylesheet" href="/css/layout.css?v=1225">
<link rel="stylesheet" href="/css/components.css?v=1225">
<link rel="stylesheet" href="/css/utilities.css?v=1225">
<link rel="stylesheet" href="/css/lists.css?v=1225">
<link rel="stylesheet" href="/css/design-system.css?v=1226">
<link rel="stylesheet" href="/css/layout.css?v=1226">
<link rel="stylesheet" href="/css/components.css?v=1226">
<link rel="stylesheet" href="/css/utilities.css?v=1226">
<link rel="stylesheet" href="/css/lists.css?v=1226">
</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=1225"></script>
<script src="/js/ui.js?v=1225"></script>
<script src="/js/app.js?v=1225"></script>
<script src="/js/worlds.js?v=1225"></script>
<script src="/js/offline-indicator.js?v=1225"></script>
<script src="/js/api.js?v=1226"></script>
<script src="/js/ui.js?v=1226"></script>
<script src="/js/app.js?v=1226"></script>
<script src="/js/worlds.js?v=1226"></script>
<script src="/js/offline-indicator.js?v=1226"></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=1225"></script>
<script src="/js/boot.js?v=1226"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1225'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1226'; // ← 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

@ -24,13 +24,27 @@
// MapLibre-GL-Karte (zentrale Karte) aus ?mapgl=1/0 — wird in pages/map.js _useGL() ausgewertet.
var mg = new URLSearchParams(location.search).get('mapgl');
if (mg !== null) localStorage.setItem('by_map_gl', mg === '0' ? '0' : '1');
// Offline-Vektorkacheln (byt://) aus ?tilesoffline=1/0 — wird in map-gl-style.js
// _offlineEnabled() ausgewertet (Staging-Default AN, localStorage übersteuert).
// Offline-Vektorkacheln (byt://) aus ?tilesoffline=1/0 — ausgewertet via BY.offlineTiles().
var to = new URLSearchParams(location.search).get('tilesoffline');
if (to !== null) localStorage.setItem('by_offline_tiles', to === '0' ? '0' : '1');
} catch (e) {}
})();
// ----------------------------------------------------------
// Zentrale Feature-Flag-Helper (boot.js lädt vor allen Modulen)
// ----------------------------------------------------------
window.BY = window.BY || {};
// Offline-Vektorkacheln (byt://): Staging-Default AN seit 2026-06-06, Production AUS
// bis Freigabe; localStorage by_offline_tiles '1'/'0' bzw. ?tilesoffline übersteuert.
window.BY.offlineTiles = function () {
try {
var flag = localStorage.getItem('by_offline_tiles');
if (flag === '1') return true;
if (flag === '0') return false;
return location.hostname === 'staging.banyaro.app';
} catch (e) { return false; }
};
// ----------------------------------------------------------
// Offline-Banner
// ----------------------------------------------------------

View file

@ -12,16 +12,9 @@
var TILES_VER = '20260605';
function tilesUrl() { return window.location.origin + '/tiles/' + TILES_FILE + '?v=' + TILES_VER; }
// Offline-Tiles-Modus (byt://-Quelle). localStorage by_offline_tiles bzw. ?tilesoffline=1/0 übersteuert.
// Staging-Default AN seit 2026-06-06 (Gerätetest); Production bleibt AUS bis Freigabe (dann analog by_map_gl).
// ACHTUNG: Default-Logik synchron halten mit offline-indicator.js _offlineTilesMode().
// Offline-Tiles-Modus (byt://-Quelle) — zentrale Flag-Logik in boot.js BY.offlineTiles().
function _offlineEnabled() {
try {
var flag = localStorage.getItem('by_offline_tiles');
if (flag === '1') return true;
if (flag === '0') return false;
return location.hostname === 'staging.banyaro.app';
} catch (e) { return false; }
try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; }
}
var THEMES = {

View file

@ -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 z1013), 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 09 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 09 ü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 z1013.
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 z1013 + Basis 09) + 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,
};
})();

View file

@ -21,15 +21,9 @@ window.OfflineIndicator = (() => {
}
// GL-Offline-Tiles-Modus (byt://-Vektorkacheln in IndexedDB) statt OSM-Raster.
// Default-Logik MUSS map-gl-style.js _offlineEnabled() entsprechen (Staging-AN seit 2026-06-06);
// dupliziert weil map-gl-style.js lazy mit der GL-Karte lädt, dieses Modul aber sofort.
// Zentrale Flag-Logik in boot.js BY.offlineTiles().
function _offlineTilesMode() {
try {
const flag = localStorage.getItem('by_offline_tiles');
if (flag === '1') return true;
if (flag === '0') return false;
return location.hostname === 'staging.banyaro.app';
} catch (e) { return false; }
try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; }
}
// Ist eine Offline-Region (Vektorkacheln) in IndexedDB gespeichert? (ohne MapOffline zu laden)
// WICHTIG: dasselbe Schema/Version wie map-offline.js anlegen — sonst legt ein versionsloses open()
@ -213,7 +207,7 @@ window.OfflineIndicator = (() => {
await Promise.all(tasks);
}
// GL-Offline: Vektor-Region (~5 km) um den aktuellen Standort in IndexedDB laden.
// GL-Offline: Gebiet (~5 MB budget-getrieben) um den aktuellen Standort in IndexedDB laden.
async function _downloadOfflineRegion() {
let pos = null;
try { pos = await API.getLocation(); } catch (e) {}
@ -226,10 +220,53 @@ window.OfflineIndicator = (() => {
if (!pos) { UI.toast.warning('Standort nötig, um die Gegend offline zu speichern.'); return; }
try {
await UI.loadMapLibreUI();
if (window.MapOffline) await MapOffline.downloadAround(pos.lat, pos.lon, 5);
if (window.MapOffline) await MapOffline.downloadAround(pos.lat, pos.lon, { budgetMB: 5 });
} catch (e) { console.warn('Offline-Region-Download fehlgeschlagen:', e); }
}
// Gibt es offene (ungefüllte) Funkloch-Zonen? — direkt aus IndexedDB, OHNE den
// GL-Stack zu laden (gleiches Schema/Version wie map-offline.js, s. Warnung oben).
function _openDeadZonesStored() {
return new Promise(res => {
try {
const r = indexedDB.open('by-offline-tiles', 1);
r.onupgradeneeded = () => {
const d = r.result;
if (!d.objectStoreNames.contains('tiles')) d.createObjectStore('tiles');
if (!d.objectStoreNames.contains('meta')) d.createObjectStore('meta');
};
r.onsuccess = () => {
const db = r.result;
if (!db.objectStoreNames.contains('meta')) { db.close(); return res(false); }
const rq = db.transaction('meta', 'readonly').objectStore('meta').get('deadzones');
rq.onsuccess = () => { res((rq.result || []).some(z => !z.filled)); db.close(); };
rq.onerror = () => { res(false); db.close(); };
};
r.onerror = () => res(false);
} catch (e) { res(false); }
});
}
// Funkloch-Zonen automatisch füllen, sobald Netz da ist — das Gerät lernt selbst,
// wo Offline-Karten nötig sind (dort wo Netz ist, braucht es keine).
let _autoFillTimer = null;
function _scheduleAutoFill(delayMs) {
if (!_offlineTilesMode()) return;
clearTimeout(_autoFillTimer);
_autoFillTimer = setTimeout(async () => {
if (!navigator.onLine) return;
try {
if (!(await _openDeadZonesStored())) return; // nichts zu tun → GL-Stack nicht laden
await UI.loadMapLibreUI();
const n = await window.MapOffline?.autoFillDeadZones?.();
if (n) {
UI.toast?.info(`${n} Funkloch-${n === 1 ? 'Gebiet' : 'Gebiete'} automatisch offline gespeichert.`);
refresh();
}
} catch (e) {}
}, delayMs);
}
// ----------------------------------------------------------
// Tile-URL-Berechnung (OSM, Subdomain 'a')
// ----------------------------------------------------------
@ -398,6 +435,9 @@ window.OfflineIndicator = (() => {
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
});
}
// Funkloch-Zonen nachladen: verzögert beim Start + sobald die Verbindung zurückkommt.
_scheduleAutoFill(30_000);
window.addEventListener('online', () => _scheduleAutoFill(8_000));
_checkStorageQuota(); // beim Init prüfen
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
}

View file

@ -375,7 +375,7 @@ window.Page_map = (() => {
});
document.getElementById('map-offline-btn')?.addEventListener('click', () => {
_sdEl?.classList.remove('open');
if (_engineGL) _downloadVectorRegion(); // GL: Vektorkacheln → IndexedDB (byt://)
if (_engineGL) _openOfflineModal(); // GL: Verwaltung (speichern/anzeigen/löschen)
else _cacheTiles(); // Leaflet: OSM-Raster → SW-Cache
});
document.getElementById('map-radar-btn')?.addEventListener('click', () => {
@ -773,16 +773,10 @@ window.Page_map = (() => {
} catch (e) { return false; }
}
// Offline-Vektorkacheln-Flag — gleiche Default-Logik wie map-gl-style.js _offlineEnabled()
// (dupliziert, weil map-gl-style.js erst mit der GL-Karte lazy lädt, das Markup aber sofort rendert).
// Offline-Vektorkacheln-Flag — zentrale Logik in boot.js BY.offlineTiles().
// Steuert nur die Button-Sichtbarkeit: im GL-Modus ohne byt://-Quelle wäre der Download nutzlos.
function _offlineTilesEnabled() {
try {
const flag = localStorage.getItem('by_offline_tiles');
if (flag === '1') return true;
if (flag === '0') return false;
return location.hostname === 'staging.banyaro.app';
} catch (e) { return false; }
try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; }
}
function loadMapLibre() {
@ -885,6 +879,7 @@ window.Page_map = (() => {
const el = document.getElementById('central-map');
if (!el || !window.maplibregl || _map) return;
_engineGL = true;
_covOn = false; // Bereiche-Layer-Status gehört zur Karten-Instanz
const center = _userPos ? [_userPos.lon, _userPos.lat] : [8.6821, 50.1109]; // Frankfurt [lng,lat]
const zoom = _userPos ? 14 : 10;
@ -2158,23 +2153,24 @@ window.Page_map = (() => {
return urls;
}
// GL-Modus: Vektorkacheln (5 km um die Kartenmitte) + Glyphs → IndexedDB (byt://).
// Gegenstück zum Welten-FAB-Download (GPS-Position) — hier zählt die KARTENMITTE,
// damit man eine entfernte Gegend (Urlaubsort) vorab speichern kann. docs/OFFLINE_MAPS_PLAN.md
// GL-Modus: Gebiet um die KARTENMITTE budget-getrieben (~5 MB) speichern — Stadt klein,
// Land groß (Ring-Wachstum in MapOffline). Kartenmitte statt GPS, damit man eine entfernte
// Gegend (Urlaubsort) vorab speichern kann. docs/OFFLINE_MAPS_PLAN.md
async function _downloadVectorRegion() {
if (!_map || !window.MapOffline) return;
const btn = document.getElementById('map-offline-btn');
if (btn?.classList.contains('loading')) return; // läuft bereits
const c = _map.getCenter();
btn?.classList.add('loading');
_setOsmStatus('Offline: 0 %…');
_setOsmStatus('Offline: 0 MB…');
try {
const res = await MapOffline.downloadAround(c.lat, c.lng, 5, p => {
_setOsmStatus(`Offline: ${Math.round(p.done / p.total * 100)} %`);
});
const res = await MapOffline.downloadAround(c.lat, c.lng, { budgetMB: 5, onProgress: p => {
_setOsmStatus(`Offline: ${(p.bytes / 1048576).toFixed(1)} / ${Math.round(p.budget / 1048576)} MB`);
} });
_setOsmStatus('');
UI.toast.success(`Gegend offline gespeichert — ${res.tiles} Kacheln, ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.`);
UI.toast.success(`Gegend offline gespeichert — ~${res.radiusKm} km Umkreis, ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.`);
window.OfflineIndicator?.refresh(); // Pfoten-Segment 5 sofort grün
if (_covOn) _setCoverage(true); // Bereiche-Layer aktualisieren
} catch (e) {
_setOsmStatus('');
UI.toast.error('Offline-Download fehlgeschlagen — bitte erneut versuchen.');
@ -2183,6 +2179,77 @@ window.Page_map = (() => {
}
}
// ----------------------------------------------------------
// Offline-Bereiche-Layer (gespeicherte z14-Kacheln) + Verwaltungs-Modal
// ----------------------------------------------------------
let _covOn = false;
async function _setCoverage(on) {
if (!_engineGL || !_map || !window.MapOffline) return false;
if (!on) {
try {
if (_map.getLayer('by-off-cov-line')) _map.removeLayer('by-off-cov-line');
if (_map.getLayer('by-off-cov')) _map.removeLayer('by-off-cov');
if (_map.getSource('by-off-cov')) _map.removeSource('by-off-cov');
} catch (e) {}
_covOn = false;
return false;
}
const gj = await MapOffline.coverage().catch(() => null);
if (!gj || !gj.features.length) { UI.toast.info('Noch keine Offline-Bereiche gespeichert.'); return false; }
if (_map.getSource('by-off-cov')) {
_map.getSource('by-off-cov').setData(gj);
} else {
_map.addSource('by-off-cov', { type: 'geojson', data: gj });
_map.addLayer({ id: 'by-off-cov', type: 'fill', source: 'by-off-cov',
paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.15 } });
_map.addLayer({ id: 'by-off-cov-line', type: 'line', source: 'by-off-cov',
paint: { 'line-color': '#3b82f6', 'line-opacity': 0.35, 'line-width': 0.5 } });
}
_covOn = true;
return true;
}
// Verwaltungs-Modal am Offline-Button: Stats + Gebiet speichern / Bereiche anzeigen / Löschen.
async function _openOfflineModal() {
if (!window.MapOffline) return;
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 totalPois = regions.reduce((a, r) => a + (r.pois || 0), 0);
UI.modal.open({
title: '🗺️ Offline-Karten',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
${regions.length
? `${regions.length} ${regions.length === 1 ? 'Gebiet' : 'Gebiete'} gespeichert — ~${(totalBytes / 1048576).toFixed(1)} MB, ${totalPois} Marker.`
: 'Noch kein Gebiet gespeichert. Karte und Marker bleiben damit auch im Funkloch verfügbar.'}
</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-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>
`,
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-cov')?.addEventListener('click', async () => { UI.modal.close(); await _setCoverage(!_covOn); });
document.getElementById('off-clear')?.addEventListener('click', async e => {
const btn = e.currentTarget;
if (btn.dataset.confirm !== '1') { // Zweiklick statt confirm-Modal im Modal
btn.dataset.confirm = '1';
btn.innerHTML = `${UI.icon('trash')} Wirklich alles löschen?`;
return;
}
await MapOffline.clear().catch(() => {});
_setCoverage(false);
UI.modal.close();
UI.toast.success('Offline-Karten gelöscht.');
window.OfflineIndicator?.refresh();
});
}
async function _cacheTiles() {
if (!_map) return;
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {
@ -2411,6 +2478,9 @@ window.Page_map = (() => {
_recDistKm += d / 1000;
}
_recTrack.push({ lat, lon });
// Funkloch-Gedächtnis: Position melden — Tile-Fetch-Fehler bei aktivem GPS
// markieren die Gegend als „Offline nötig" (lokal, map-offline.js).
window.MapOffline?.setGps({ lat, lon });
_persistRec();
_updateRecMap(lat, lon);
_updateRecStatus();
@ -2557,6 +2627,7 @@ window.Page_map = (() => {
if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; }
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
_recActive = false;
window.MapOffline?.setGps(null); // Funkloch-Erkennung nur bei aktiver Aufzeichnung
_releaseWakeLock();
_hidePocketOverlay();
document.removeEventListener('visibilitychange', _onVisibilityChange);

View file

@ -2437,6 +2437,7 @@ window.Page_routes = (() => {
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
${_actionBtn('rd-navi', 'map-pin', 'Navi')}
${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
${(window.BY?.offlineTiles?.() && track.length >= 2) ? _actionBtn('rd-offline', 'cloud-arrow-down', 'Offline') : ''}
</div>
${ownerRow}
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
@ -2460,6 +2461,31 @@ window.Page_routes = (() => {
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
// Route offline speichern: Kachel-Korridor ±1 km um den Track + Marker → IndexedDB
// (für mehrtägige Unternehmungen entlang der Route, docs/OFFLINE_MAPS_PLAN.md).
document.getElementById('rd-offline')?.addEventListener('click', async () => {
const btn = document.getElementById('rd-offline');
if (!btn || btn.dataset.busy) return;
btn.dataset.busy = '1';
const label = btn.querySelector('span');
try {
await UI.loadMapLibreUI(); // lädt pmtiles + map-offline (byt://-Stack) bei Bedarf
const res = await MapOffline.downloadCorridor(track, {
bufferKm: 1, name: route.name,
onProgress: p => { if (label) label.textContent = `${(p.bytes / 1048576).toFixed(1)} MB`; },
});
if (label) label.textContent = 'Offline ✓';
UI.toast.success(`Route offline gespeichert — Korridor ±1 km, ${res.pois || 0} Marker, `
+ `${(res.bytes / 1048576).toFixed(1)} MB.${res.capped ? ' (50-MB-Limit erreicht)' : ''}`);
window.OfflineIndicator?.refresh();
} catch (e) {
if (label) label.textContent = 'Offline';
UI.toast.error('Offline-Speichern fehlgeschlagen.');
} finally {
delete btn.dataset.busy;
}
});
// Teilen-Button
document.getElementById('rd-share')?.addEventListener('click', async () => {
const shareUrl = location.origin + '/#routes?id=' + route.id;

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=1225"></script>
<script src="/js/landing-init.js?v=1226"></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 = '1225';
const VER = '1226';
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

@ -1,7 +1,7 @@
# Offline-Karten (GL/Vektor) — Feature-Plan
**Status:** KERN + Follow-ups Runde 1 umgesetzt, **Staging-Default AN seit 2026-06-06 (v1222)** — Gerätetest ausstehend; Production AUS bis Freigabe.
**Stand:** 2026-06-06. Autor: René + Claude (Design).
**Status:** Runde 2 (adaptives Modell) umgesetzt — **Staging-Default AN**; Production AUS bis Freigabe.
**Stand:** 2026-06-07. Autor: René + Claude (Design).
## Umsetzungsstand (2026-06-06, v1222 auf Staging)
**✅ Fertig + headless bewiesen (2026-06-05, v1213):**
@ -38,11 +38,32 @@
- **Offline-Banner** klappt 5 s nach Offline-Gang auf schmale Icon-Leiste ein (volles Banner verdeckte
die Karten-Legende); Banner-Styles von index.html-Inline nach components.css konsolidiert.
**🔲 Offen (Follow-ups Runde 2):**
- **Gerätetest-Nachtest** (Marker offline + Banner-Einklappen, v1223) → dann Prod-Freigabe-Entscheidung
(Default-Hostnames erweitern analog `by_map_gl`).
- **Adaptives Lernen** (rollendes Vorausladen beim Aufzeichnen + Funkloch-Gedächtnis).
- **Bereichsauswahl / Routen-Korridor** (inkl. „Route offline speichern" aus routes.js `_openDetail`).
**✅ Runde 2 — adaptives Modell (2026-06-07, Design René 2026-06-06):**
- **Budget-Download statt fester Radius:** `downloadAround(lat, lon, {budgetMB:5})` expandiert z14-Ringe
(+ Eltern z1013, Basis z09 immer dabei) um den Standort, bis **5 MB GESPEICHERTE Bytes**
(dekomprimiert, IndexedDB) erreicht sind → Stadt ~1,53 km, Land ~610 km Radius — passend zur
Funknetzdichte. **CLIENT-seitig — der geplante Server-Region-Extract-Endpoint ist NICHT nötig.**
- **Funkloch-Gedächtnis:** Tile-Remote-Miss bei aktivem GPS (map.js Recording speist
`MapOffline.setGps`) → `markDeadZone` (Dedupe 2 km, Cap 50, **komplett lokal, nie hochgeladen**).
`autoFillDeadZones()` lädt offene Zonen budget-getrieben nach, sobald online (Trigger:
offline-indicator init +30 s, `online`-Event +8 s; Vorab-Check ohne GL-Stack-Load).
- **Routen-Korridor:** `downloadCorridor(track, {bufferKm:1, capMB:50})` + Button „Offline" im
Routen-Detail (`rd-offline`, flag-gated) — Kacheln ±1 km um den Track + Marker der Korridor-Bbox.
- **Coverage-Layer:** `MapOffline.coverage()` (GeoJSON der gespeicherten z14-Kacheln) als blauer
GL-Fill-Layer; Offline-Button öffnet jetzt ein **Verwaltungs-Modal** (Gebiete/MB/Marker-Stats,
Gebiet speichern, Bereiche ein-/ausblenden, Alles löschen per Zweiklick).
- Flag-Logik zentralisiert: `boot.js window.BY.offlineTiles()` (vorher 3× dupliziert).
- Meta neu: `regions`-Liste (Cap 30) + `deadzones`; `region` (letztes Gebiet) bleibt für Back-Compat.
**🔲 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).