diff --git a/VERSION b/VERSION
index 337173d..872362f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1225
\ No newline at end of file
+1226
\ No newline at end of file
diff --git a/backend/static/index.html b/backend/static/index.html
index 59761f6..6d70e7a 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -86,14 +86,14 @@
Ban Yaro
-
+
-
-
-
-
-
+
+
+
+
+
@@ -612,11 +612,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -626,7 +626,7 @@
-
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 27286f7..ab7739f 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -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;
diff --git a/backend/static/js/boot.js b/backend/static/js/boot.js
index 5245378..c564225 100644
--- a/backend/static/js/boot.js
+++ b/backend/static/js/boot.js
@@ -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
// ----------------------------------------------------------
diff --git a/backend/static/js/map-gl-style.js b/backend/static/js/map-gl-style.js
index 97da80d..0dab253 100644
--- a/backend/static/js/map-gl-style.js
+++ b/backend/static/js/map-gl-style.js
@@ -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 = {
diff --git a/backend/static/js/map-offline.js b/backend/static/js/map-offline.js
index 5a3c408..bc99759 100644
--- a/backend/static/js/map-offline.js
+++ b/backend/static/js/map-offline.js
@@ -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,
};
})();
diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js
index 7d7e9c4..cb6bf6e 100644
--- a/backend/static/js/offline-indicator.js
+++ b/backend/static/js/offline-indicator.js
@@ -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);
}
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js
index 06e77e1..709aedf 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -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: `
+
+ ${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.'}
+
+
+
+
+ ${regions.length ? `` : ''}
+
+ `,
+ footer: ``,
+ });
+ 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);
diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js
index 4686f15..1a59698 100644
--- a/backend/static/js/pages/routes.js
+++ b/backend/static/js/pages/routes.js
@@ -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') : ''}
${ownerRow}
@@ -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;
diff --git a/backend/static/landing.html b/backend/static/landing.html
index aafb5f5..c6ceae8 100644
--- a/backend/static/landing.html
+++ b/backend/static/landing.html
@@ -4,7 +4,7 @@
-
+
Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz
diff --git a/backend/static/sw.js b/backend/static/sw.js
index 6e5fb87..3a425c7 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -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
diff --git a/docs/OFFLINE_MAPS_PLAN.md b/docs/OFFLINE_MAPS_PLAN.md
index 6e6dc76..32b529e 100644
--- a/docs/OFFLINE_MAPS_PLAN.md
+++ b/docs/OFFLINE_MAPS_PLAN.md
@@ -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 z10–13, Basis z0–9 immer dabei) um den Standort, bis **5 MB GESPEICHERTE Bytes**
+ (dekomprimiert, IndexedDB) erreicht sind → Stadt ~1,5–3 km, Land ~6–10 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).