diff --git a/VERSION b/VERSION
index ededf28..1fdcf1c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1220
\ No newline at end of file
+1228
\ No newline at end of file
diff --git a/backend/main.py b/backend/main.py
index bf4f74b..e399801 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -2601,6 +2601,18 @@ async def wurfboerse_page():
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=1800"})
+# Rechtsseiten: Pfad-URLs (SEO-Footer, App-Store-Metadaten, E-Mails) auf die
+# SPA-Hash-Routen umleiten — die Inhalte leben als SPA-Seiten (#agb, …).
+# Muss VOR dem SPA-Fallback registriert sein.
+@app.get("/agb")
+@app.get("/datenschutz")
+@app.get("/impressum")
+async def legal_page_redirect(request: _Request):
+ from fastapi.responses import RedirectResponse
+ page = request.url.path.strip("/")
+ return RedirectResponse(f"/#{page}", status_code=302)
+
+
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 87dea9c..5b2a7dd 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -7294,14 +7294,25 @@ svg.empty-state-icon {
left: 0;
right: 0;
z-index: 9999;
- background: var(--c-text-secondary, #6b7280);
- color: #fff;
- font-size: var(--text-sm);
+ background: #1f2937;
+ color: #f3f4f6;
+ font-size: 0.78rem;
+ font-weight: 500;
text-align: center;
- padding: var(--space-2) var(--space-4);
+ padding: calc(env(safe-area-inset-top, 0px) + 7px) 16px 7px;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ box-shadow: 0 2px 8px rgba(0,0,0,.3);
pointer-events: none;
letter-spacing: 0.01em;
}
+/* Eingeklappt (5s nach Offline-Gang, boot.js): schmale Icon-Leiste statt 2-Zeilen-Banner —
+ das volle Banner verdeckte die Karten-Steuerung oben (Gerätetest iOS 2026-06-06). */
+#offline-banner.collapsed {
+ padding: calc(env(safe-area-inset-top, 0px) + 2px) 16px 2px;
+}
+#offline-banner.collapsed #offline-banner-text { display: none; }
/* ------------------------------------------------------------
STREAK-WIDGET (Welcome-Seite)
diff --git a/backend/static/index.html b/backend/static/index.html
index 9b1271c..ab2e972 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -86,24 +86,19 @@
+
+
@@ -617,11 +612,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -631,7 +626,7 @@
-
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index a55f2a6..468016a 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 = '1220'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '1228'; // ← 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;
@@ -1123,7 +1123,12 @@ const App = (() => {
const _rawHash = location.hash.replace('#', '');
const _hashQuery = _rawHash.split('?')[1] || '';
const _hashP = new URLSearchParams(_hashQuery);
- if (_hashP.get('verified') || _hashP.get('token') || location.pathname.startsWith('/teilen/')) {
+ // Rechtsseiten direkt verlinkt (iOS-App, App-Store-Metadaten, E-Mails,
+ // /impressum-Redirects) → ebenfalls in App bleiben statt /info
+ const _hashPage = _rawHash.split('?')[0];
+ const _legalPages = ['impressum', 'datenschutz', 'agb'];
+ if (_hashP.get('verified') || _hashP.get('token') || location.pathname.startsWith('/teilen/')
+ || _legalPages.includes(_hashPage)) {
sessionStorage.setItem('by_stay_in_app', '1');
}
@@ -1198,7 +1203,11 @@ const App = (() => {
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
- navigate(state.user ? startPage : 'welcome', false, hashParams);
+ // Hash-Route auch ohne Login ansteuern — öffentliche Seiten (AGB,
+ // Datenschutz, Impressum, …) müssen für anonyme Besucher erreichbar sein.
+ // Auth-pflichtige Seiten leitet navigate() über den requiresAuth-Guard
+ // selbst auf 'welcome' um.
+ navigate(startPage, false, hashParams);
if (window.Worlds && state.user) window.Worlds.init(state);
}
diff --git a/backend/static/js/boot.js b/backend/static/js/boot.js
index 85becee..09e1624 100644
--- a/backend/static/js/boot.js
+++ b/backend/static/js/boot.js
@@ -24,17 +24,44 @@
// 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 — 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://): Default AN auf allen deployten Hosts (Prod + Staging),
+// localhost bleibt AUS; localStorage by_offline_tiles '1'/'0' bzw. ?tilesoffline übersteuert.
+// Prod-Freigabe René 2026-06-07 (analog by_map_gl, Gerätetests Runde 1+2 bestanden).
+window.BY.offlineTiles = function () {
+ try {
+ var flag = localStorage.getItem('by_offline_tiles');
+ if (flag === '1') return true;
+ if (flag === '0') return false;
+ return /(^|\.)banyaro\.(app|de)$/.test(location.hostname);
+ } catch (e) { return false; }
+};
+
// ----------------------------------------------------------
// Offline-Banner
// ----------------------------------------------------------
(function() {
+ var _collapseTimer = null;
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
+ clearTimeout(_collapseTimer);
+ banner.classList.remove('collapsed');
banner.style.display = navigator.onLine ? 'none' : 'flex';
+ // Nach 5s auf schmale Icon-Leiste einklappen — das volle Banner verdeckt
+ // sonst die Steuerung oben (z.B. Karten-Legende; Gerätetest iOS 2026-06-06).
+ if (!navigator.onLine) {
+ _collapseTimer = setTimeout(function() { banner.classList.add('collapsed'); }, 5000);
+ }
}
window.addEventListener('offline', function() {
_updateBanner();
diff --git a/backend/static/js/landing-init.js b/backend/static/js/landing-init.js
index b4bcb53..ada2d1d 100644
--- a/backend/static/js/landing-init.js
+++ b/backend/static/js/landing-init.js
@@ -64,7 +64,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Live-Zahlen
var fmt = new Intl.NumberFormat('de-DE');
fetch('/api/stats/public')
- .then(function(r) { return r.json(); })
+ // r.ok prüfen: der SW antwortet offline mit 503+JSON ({detail:…}) → json() wirft nicht
+ .then(function(r) { if (!r.ok) throw new Error('stats ' + r.status); return r.json(); })
.then(function(d) {
function set(id, val) {
var el = document.getElementById(id);
diff --git a/backend/static/js/map-gl-style.js b/backend/static/js/map-gl-style.js
index 9a02bc9..0dab253 100644
--- a/backend/static/js/map-gl-style.js
+++ b/backend/static/js/map-gl-style.js
@@ -12,10 +12,9 @@
var TILES_VER = '20260605';
function tilesUrl() { return window.location.origin + '/tiles/' + TILES_FILE + '?v=' + TILES_VER; }
- // Offline-Tiles-Modus (byt://-Quelle). Opt-in via localStorage by_offline_tiles='1' bzw. ?tilesoffline=1.
- // Default AUS, bis auf Gerät verifiziert — dann hier auf Staging-Default umstellen (analog by_map_gl).
+ // Offline-Tiles-Modus (byt://-Quelle) — zentrale Flag-Logik in boot.js BY.offlineTiles().
function _offlineEnabled() {
- try { return localStorage.getItem('by_offline_tiles') === '1'; } catch (e) { return false; }
+ try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; }
}
var THEMES = {
@@ -52,7 +51,11 @@
: { type: 'vector', url: 'pmtiles://' + tilesUrl() };
return {
version: 8,
- glyphs: window.location.origin + '/fonts/{fontstack}/{range}.pbf',
+ // Offline-Modus: Glyphs übers byt://f/-Protokoll (IndexedDB-first, remote-Fallback) —
+ // der SW-Cache für /fonts wird bei App-Updates gepurged, IndexedDB nicht.
+ glyphs: useOffline
+ ? 'byt://f/{fontstack}/{range}'
+ : window.location.origin + '/fonts/{fontstack}/{range}.pbf',
sources: {
by: src,
},
diff --git a/backend/static/js/map-offline.js b/backend/static/js/map-offline.js
index 76741ee..127c286 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,21 +50,29 @@ 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
+ });
});
}
- // MapLibre-Protokoll `byt://t/{z}/{x}/{y}` registrieren (idempotent).
+ // MapLibre-Protokolle registrieren (idempotent):
+ // byt://t/{z}/{x}/{y} → Vektorkachel (MVT)
+ // byt://f/{fontstack}/{range} → Glyph-PBF (fontstack ggf. URL-encodiert, je nach MapLibre-Version)
function registerProtocol() {
if (registerProtocol._done || typeof maplibregl === 'undefined') return;
registerProtocol._done = true;
maplibregl.addProtocol('byt', function (params) {
- var m = /byt:\/\/t\/(\d+)\/(\d+)\/(\d+)/.exec(params.url);
- if (!m) return Promise.resolve({ data: new ArrayBuffer(0) });
- return tile(+m[1], +m[2], +m[3]).then(function (u) {
+ var ret = function (u) {
if (!u) return { data: new ArrayBuffer(0) };
return { data: u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) };
- });
+ };
+ var t = /byt:\/\/t\/(\d+)\/(\d+)\/(\d+)/.exec(params.url);
+ if (t) return tile(+t[1], +t[2], +t[3]).then(ret);
+ var f = /byt:\/\/f\/([^/]+)\/(\d+-\d+)/.exec(params.url);
+ if (f) return glyph(decodeURIComponent(f[1]), f[2]).then(ret);
+ return Promise.resolve({ data: new ArrayBuffer(0) });
});
}
@@ -72,60 +82,360 @@ window.MapOffline = (function () {
var r = lat * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * Math.pow(2, z));
}
- function _tileList(lat, lon, radiusKm) {
+ function _bboxAround(lat, lon, radiusKm) {
var dLat = radiusKm / 111, dLon = radiusKm / (111 * Math.cos(lat * Math.PI / 180));
- var w = lon - dLon, e = lon + dLon, s = lat - dLat, n = lat + dLat, list = [];
- for (var z = 0; z <= MAXZOOM; z++) {
- var x0 = _x(w, z), x1 = _x(e, z), y0 = _y(n, z), y1 = _y(s, z);
- for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) list.push([z, x, y]);
- }
- return list;
+ return { south: lat - dLat, west: lon - dLon, north: lat + dLat, east: lon + dLon };
}
- // Glyphs (Open Sans Regular/Semibold, Latin + Latin-Extended) holen, damit der Service-Worker sie cacht.
+ // 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
+ // SW-Cache, der beim Update gepurged wird. Offline serviert übers byt://f/-Protokoll.
// KRITISCH: ohne Glyphs lässt MapLibre offline die GANZE Kachel fallen (nicht nur die Labels) → leer.
- // 0-255 + 256-511 deckt DE/FR/PL/CZ/IT-Sonderzeichen ab. (Persistenz über App-Updates = Follow-up.)
+ // 0-255 + 256-511 deckt DE/FR/PL/CZ/IT-Sonderzeichen ab.
var FONTS = ['Open Sans Regular', 'Open Sans Semibold'], RANGES = ['0-255', '256-511'];
+ function _glyphUrl(font, range) { return '/fonts/' + encodeURIComponent(font) + '/' + range + '.pbf'; }
function _cacheGlyphs() {
var bytes = 0, jobs = [];
FONTS.forEach(function (f) { RANGES.forEach(function (rg) {
- jobs.push(fetch('/fonts/' + encodeURIComponent(f) + '/' + rg + '.pbf')
+ jobs.push(fetch(_glyphUrl(f, rg))
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
- .then(function (b) { if (b) bytes += b.byteLength; })
+ .then(function (b) {
+ if (!b) return;
+ bytes += b.byteLength;
+ return _put('f/' + f + '/' + rg, new Uint8Array(b));
+ })
.catch(function () {}));
}); });
return Promise.all(jobs).then(function () { return bytes; });
}
- // Bereich um lat/lon (radiusKm, Default 5) herunterladen + in IndexedDB ablegen.
- // onProgress({done,total,bytes}). Liefert {tiles,bytes}.
- 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;
+ // Glyph-Bytes für fontstack/range — IndexedDB zuerst, sonst remote (online), sonst null.
+ function glyph(font, range) {
+ return _get('f/' + font + '/' + range).then(function (hit) {
+ if (hit) return hit instanceof Uint8Array ? hit : new Uint8Array(hit);
+ return fetch(_glyphUrl(font, range))
+ .then(function (r) { return r.ok ? r.arrayBuffer() : null; })
+ .then(function (b) { return b ? new Uint8Array(b) : null; })
+ .catch(function () { return null; });
+ });
+ }
+
+ // POI-Marker der Region mitspeichern (Key-Präfix 'p/
' im Tiles-Store) — sonst steht die
+ // Offline-Karte ohne Marker da (Gerätetest 2026-06-06). Quelle: /api/osm/pois (liest die lokale
+ // osm_pois-DB, fast=true). Typen = Werte von OSM_LAYER_MAP in pages/map.js (synchron halten).
+ var POI_TYPES = ['waste_basket', 'dog_park', 'drinking_water', 'tierarzt', 'hundesalon', 'shop',
+ 'restaurant', 'bank', 'giftkoeder', 'kotbeutel', 'gefahr', 'parkplatz',
+ 'treffpunkt', 'sonstiges', 'hotel'];
+
+ // Frische Liste mit Bestand mergen (per id) — eine zweite Region (Urlaubsort) darf die erste
+ // nicht löschen. Liefert die Anzahl frischer Einträge.
+ function _mergeStore(key, fresh) {
+ if (!fresh || !fresh.length) return Promise.resolve(0);
+ return _get(key).then(function (old) {
+ var merged = fresh;
+ if (old && old.length) {
+ var seen = {};
+ fresh.forEach(function (p) { seen[p.id] = true; });
+ merged = fresh.concat(old.filter(function (p) { return !seen[p.id]; }));
+ }
+ return _put(key, merged).then(function () { return fresh.length; });
+ });
+ }
+
+ function _cachePois(bbox) {
+ var total = 0;
+ var jobs = POI_TYPES.map(function (type) {
+ var params = new URLSearchParams({ type: type, fast: 'true',
+ south: bbox.south, west: bbox.west, north: bbox.north, east: bbox.east });
+ return fetch('/api/osm/pois?' + params)
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (fresh) { return _mergeStore('p/' + type, fresh); })
+ .then(function (n) { total += n; })
+ .catch(function () {});
+ });
+
+ // Sicherheitsdaten MÜSSEN offline da sein (René 2026-06-07): Giftköder-Alarme
+ // (/api/poison, Radius in m) + vermisste Hunde (/api/lost, Radius in km) — beide anonym.
+ var midLat = (bbox.south + bbox.north) / 2, midLon = (bbox.west + bbox.east) / 2;
+ var radiusKm = Math.max(
+ (bbox.north - bbox.south) * 111 / 2,
+ (bbox.east - bbox.west) * 111 * Math.cos(midLat * Math.PI / 180) / 2, 1);
+ jobs.push(fetch('/api/poison?lat=' + midLat + '&lon=' + midLon + '&radius=' + Math.round(radiusKm * 1000))
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (fresh) { return _mergeStore('p/_poison', fresh); })
+ .then(function (n) { total += n; })
+ .catch(function () {}));
+ jobs.push(fetch('/api/lost?lat=' + midLat + '&lon=' + midLon + '&radius_km=' + Math.ceil(radiusKm))
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (fresh) { return _mergeStore('p/_lost', fresh); })
+ .then(function (n) { total += n; })
+ .catch(function () {}));
+
+ return Promise.all(jobs).then(function () { return total; });
+ }
+
+ // Gespeicherte Sicherheits-Alarme ('poison' | 'lost') im Bbox-Ausschnitt — Offline-Fallback.
+ function alerts(kind, bbox) {
+ return _get('p/_' + kind).then(function (list) {
+ if (!list || !list.length) return [];
+ return list.filter(function (p) {
+ return p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east;
+ });
+ }).catch(function () { return []; });
+ }
+
+ // Gespeicherte POIs eines Typs im Bbox-Ausschnitt — Offline-Fallback für die Karten-Marker.
+ function pois(type, bbox) {
+ return _get('p/' + type).then(function (list) {
+ if (!list || !list.length) return [];
+ return list.filter(function (p) {
+ return p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east;
+ });
+ }).catch(function () { return []; });
+ }
+
+ // 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 _req(META, 'readwrite', function (os) {
- os.put({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: stored, bytes: bytes, savedAt: Date.now() }, 'region');
- }); })
- .then(function () { return { tiles: stored, bytes: bytes }; });
+ 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; }); }
@@ -135,7 +445,9 @@ window.MapOffline = (function () {
}
return {
- registerProtocol: registerProtocol, downloadAround: downloadAround, tile: tile,
+ registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor,
+ tile: tile, glyph: glyph, pois: pois, alerts: alerts, 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 55e93b1..cb6bf6e 100644
--- a/backend/static/js/offline-indicator.js
+++ b/backend/static/js/offline-indicator.js
@@ -21,8 +21,9 @@ window.OfflineIndicator = (() => {
}
// GL-Offline-Tiles-Modus (byt://-Vektorkacheln in IndexedDB) statt OSM-Raster.
+ // Zentrale Flag-Logik in boot.js BY.offlineTiles().
function _offlineTilesMode() {
- try { return localStorage.getItem('by_offline_tiles') === '1'; } 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()
@@ -206,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) {}
@@ -219,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')
// ----------------------------------------------------------
@@ -373,7 +417,9 @@ window.OfflineIndicator = (() => {
function init() {
refresh();
_prefetchPages();
- _prefetchTiles();
+ // OSM-Raster-Prefetch nur für die Leaflet-Karte — die GL-Karte (byt://-Vektorkacheln)
+ // nutzt das Raster nicht. Komplett-Entfernung wenn Flag dauerhaft AN (OFFLINE_MAPS_PLAN.md).
+ if (!_offlineTilesMode()) _prefetchTiles();
_prefetchData();
_bindLongPress();
@@ -389,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/lost.js b/backend/static/js/pages/lost.js
index 30d18e5..e2c92e9 100644
--- a/backend/static/js/pages/lost.js
+++ b/backend/static/js/pages/lost.js
@@ -233,10 +233,25 @@ window.Page_lost = (() => {
...p,
distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon),
}));
+ // Offline-Region-Snapshot (Offline-Karten speichern vermisste Hunde mit) dazu mergen —
+ // deckt vorab gespeicherte Gegenden ab, die der localStorage-Stand nicht kennt.
+ let regionLost = [];
+ try {
+ if (window.MapOffline?.alerts) {
+ regionLost = await MapOffline.alerts('lost', {
+ south: _userPos.lat - 0.3, north: _userPos.lat + 0.3,
+ west: _userPos.lon - 0.45, east: _userPos.lon + 0.45,
+ });
+ }
+ } catch {}
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw).data || [];
+ const seen = new Set(cached.map(r => r.id));
+ regionLost.filter(r => !seen.has(r.id)).forEach(r => cached.push({
+ ...r, distanz_m: _haversine(_userPos.lat, _userPos.lon, r.lat, r.lon),
+ }));
_reports = [...offline_pending, ...cached];
_renderMarkers();
_renderHeld();
@@ -246,12 +261,16 @@ window.Page_lost = (() => {
return;
}
} catch {}
- _reports = offline_pending;
- if (offline_pending.length) {
+ // Kein localStorage-Stand → wenigstens Pending + Region-Snapshot zeigen
+ _reports = [...offline_pending, ...regionLost.map(r => ({
+ ...r, distanz_m: _haversine(_userPos.lat, _userPos.lon, r.lat, r.lon),
+ }))];
+ if (_reports.length) {
_renderMarkers();
_renderHeld();
_renderList();
_updateBadge(_reports.length);
+ if (infoEl) infoEl.textContent = 'Offline — zeige gespeicherte Meldungen.';
return;
}
UI.toast.error('Meldungen konnten nicht geladen werden.');
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js
index 9f06461..e62f653 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -257,6 +257,11 @@ window.Page_map = (() => {
Marker setzen
+ ${(!_useGL() || _offlineTilesEnabled()) ? `
+
+ Karte offline speichern
+
+
` : ''}
${App.hasPro(_appState?.user) ? `
Regenradar
@@ -368,6 +373,11 @@ window.Page_map = (() => {
_sdEl?.classList.remove('open');
_togglePlacementMode();
});
+ document.getElementById('map-offline-btn')?.addEventListener('click', () => {
+ _sdEl?.classList.remove('open');
+ if (_engineGL) _openOfflineModal(); // GL: Verwaltung (speichern/anzeigen/löschen)
+ else _cacheTiles(); // Leaflet: OSM-Raster → SW-Cache
+ });
document.getElementById('map-radar-btn')?.addEventListener('click', () => {
_sdEl?.classList.remove('open');
_toggleRadar();
@@ -763,6 +773,12 @@ window.Page_map = (() => {
} catch (e) { return false; }
}
+ // 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 { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; }
+ }
+
function loadMapLibre() {
if (_maplibreLoaded) return Promise.resolve();
const v = '?v=' + (window.APP_VER || '');
@@ -863,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;
@@ -1447,15 +1464,33 @@ window.Page_map = (() => {
}
}
+ // POIs holen — WICHTIG: r.ok prüfen! Der SW antwortet offline auf nicht-cachebare
+ // API-GETs mit 503 + JSON-Body ({detail:…}) → r.json() wirft NICHT, der Erfolgs-Pfad
+ // liefe mit einem Objekt statt Array weiter und ersetzte die Marker durch nichts.
+ const _fetchPois = async (params) => {
+ const r = await fetch(`/api/osm/pois?${params}`);
+ if (!r.ok) throw new Error(`pois ${r.status}`);
+ const pois = await r.json();
+ return Array.isArray(pois) ? pois : [];
+ };
+
// Phase 1: sofort DB-Daten zeigen (fast=true)
_setOsmStatus('Lade…');
const fastTasks = activeLayers.map(async ([layerKey, osmType]) => {
const params = new URLSearchParams({ type: osmType, fast: 'true', ...bbox });
try {
- const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
+ const pois = await _fetchPois(params);
_replaceOsmMarkers(layerKey, pois);
return pois.length;
- } catch { return 0; }
+ } catch {
+ // Offline: gespeicherte Region-POIs aus IndexedDB (MapOffline.downloadAround
+ // legt sie beim Region-Download mit ab) statt leerer Karte.
+ try {
+ const off = window.MapOffline ? await MapOffline.pois(osmType, bbox) : [];
+ if (off.length) { _replaceOsmMarkers(layerKey, off); return off.length; }
+ } catch (e) {}
+ return 0;
+ }
});
const fastCounts = await Promise.all(fastTasks);
const fastTotal = fastCounts.reduce((a, b) => a + b, 0);
@@ -1469,7 +1504,7 @@ window.Page_map = (() => {
const freshTasks = activeLayers.map(async ([layerKey, osmType]) => {
const params = new URLSearchParams({ type: osmType, ...bbox });
try {
- const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
+ const pois = await _fetchPois(params);
const osmCount = _osmCountOf(layerKey);
if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois);
_done++;
@@ -1825,6 +1860,9 @@ window.Page_map = (() => {
credentials: 'include', body: JSON.stringify(body),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
+ // res.ok prüfen: SW antwortet offline mit 503+JSON → json() wirft nicht,
+ // sonst Erfolgs-Toast obwohl nichts gemeldet wurde. (202 = offline gequeued = ok.)
+ if (!res.ok) throw new Error(`report ${res.status}`);
const data = await res.json();
if (data.status === 'bereits_gemeldet') {
UI.toast.info('Du hast diesen Marker bereits gemeldet.');
@@ -1880,29 +1918,40 @@ window.Page_map = (() => {
API.breeder.mapMarkers(),
]);
+ // Offline-Fallback PRO QUELLE (nicht alles-oder-nichts): Der SW cached /api/places und
+ // /api/breeder/map-markers (feste URLs), aber /api/poison?lat=… ändert sich mit jeder
+ // Position → Cache-Miss → vorher verschwanden offline ausgerechnet die GIFTKÖDER,
+ // während places aus dem SW-Cache kam und den allFailed-Fallback verhinderte
+ // (Gerätetest 2026-06-07). Jede Quelle fällt einzeln auf den letzten guten Stand zurück.
+ let cached = null;
+ try { cached = JSON.parse(localStorage.getItem(_MAP_POI_KEY) || 'null'); } catch {}
const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected');
- if (allFailed) {
+
+ const placesVal = places.status === 'fulfilled' ? places.value : (cached?.places || []);
+ let poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : (cached?.poison || []);
+ const breederVal = breederList.status === 'fulfilled' ? breederList.value : (cached?.breeders || []);
+
+ // Giftköder zusätzlich aus dem Offline-Region-Snapshot (deckt vorab gespeicherte
+ // Gegenden ab, wo der localStorage-Stand der letzten Position nicht hinreicht).
+ if (poisonList.status === 'rejected' && window.MapOffline?.alerts) {
try {
- const raw = localStorage.getItem(_MAP_POI_KEY);
- if (raw) {
- const cached = JSON.parse(raw);
- _addPlaces(cached.places || []);
- _addPoison(cached.poison || []);
- _addBreeders(cached.breeders || []);
- UI.toast.info('Offline — Karte zeigt gecachte Kacheln. POI-Daten eventuell veraltet.');
- _scheduleOsmLoad();
- return;
+ const c = _map ? _map.getCenter() : (_userPos ? { lat: _userPos.lat, lng: _userPos.lon } : null);
+ if (c) {
+ const off = await MapOffline.alerts('poison',
+ { south: c.lat - 0.5, north: c.lat + 0.5, west: c.lng - 0.7, east: c.lng + 0.7 });
+ const seen = new Set(poisonVal.map(p => p.id));
+ poisonVal = poisonVal.concat(off.filter(p => !seen.has(p.id)));
}
} catch {}
}
- const placesVal = places.status === 'fulfilled' ? places.value : [];
- const poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : [];
- const breederVal = breederList.status === 'fulfilled' ? breederList.value : [];
+ if (allFailed && (placesVal.length || poisonVal.length || breederVal.length)) {
+ UI.toast.info('Offline — Karte zeigt zuletzt geladene Daten.');
+ }
- if (places.status === 'fulfilled') _addPlaces(placesVal);
- if (poisonList.status === 'fulfilled') _addPoison(poisonVal);
- if (breederList.status === 'fulfilled') _addBreeders(breederVal);
+ _addPlaces(placesVal);
+ _addPoison(poisonVal);
+ _addBreeders(breederVal);
if (places.status === 'fulfilled' || poisonList.status === 'fulfilled' || breederList.status === 'fulfilled') {
try {
@@ -2115,6 +2164,103 @@ window.Page_map = (() => {
return urls;
}
+ // 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 MB…');
+ try {
+ 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.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.');
+ } finally {
+ btn?.classList.remove('loading');
+ }
+ }
+
+ // ----------------------------------------------------------
+ // 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) {
@@ -2343,6 +2489,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();
@@ -2489,6 +2638,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 23e3381..68729c7 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,45 @@ 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();
+ // Gespeicherte Bereiche sofort auf der Detailkarte zeigen (blau) — sonst ist der
+ // Korridor „unsichtbar", v.a. wenn er im schon gespeicherten Gebiet liegt.
+ try {
+ const gl = _detailMap?._gl;
+ if (gl) {
+ const gj = await MapOffline.coverage();
+ if (gl.getSource('rd-off-cov')) gl.getSource('rd-off-cov').setData(gj);
+ else {
+ gl.addSource('rd-off-cov', { type: 'geojson', data: gj });
+ gl.addLayer({ id: 'rd-off-cov', type: 'fill', source: 'rd-off-cov',
+ paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.15 } });
+ }
+ }
+ } catch (e) {}
+ } 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;
@@ -2762,8 +2802,11 @@ window.Page_routes = (() => {
await Promise.all(NEARBY_TYPES.map(async ({ type, icon, label, svgIcon, color }) => {
try {
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
- const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
- pois
+ // r.ok prüfen: SW antwortet offline mit 503+JSON ({detail:…}) → json() wirft nicht
+ const r = await fetch(`/api/osm/pois?${params}`);
+ if (!r.ok) throw new Error(`pois ${r.status}`);
+ const pois = await r.json();
+ (Array.isArray(pois) ? pois : [])
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
.forEach(p => results.push({ ...p, _icon: icon, _label: label, _svgIcon: svgIcon, _color: color }));
} catch {}
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js
index e59aaeb..a9446bb 100644
--- a/backend/static/js/pages/settings.js
+++ b/backend/static/js/pages/settings.js
@@ -1346,6 +1346,9 @@ window.Page_settings = (() => {
try {
// Versionsnummer direkt vom API-Endpunkt holen
const r = await fetch('/api/version', { cache: 'no-store' });
+ // r.ok prüfen: SW antwortet offline mit 503+JSON → json() wirft nicht und
+ // serverVersion=undefined meldete fälschlich „Ban Yaro ist aktuell".
+ if (!r.ok) throw new Error(`version ${r.status}`);
const { version: serverVersion } = await r.json();
const localVersion = typeof APP_VER !== 'undefined' ? APP_VER : '0';
diff --git a/backend/static/js/pages/social.js b/backend/static/js/pages/social.js
index 4a81765..b87cf8d 100644
--- a/backend/static/js/pages/social.js
+++ b/backend/static/js/pages/social.js
@@ -377,7 +377,12 @@ window.Page_social = (() => {
method: 'POST',
headers: {Authorization: `Bearer ${localStorage.getItem('by_token')}`},
body: fd,
- }).then(r => r.json()).then(d => { uploadedMediaUrl = d.url; });
+ }).then(r => {
+ // r.ok prüfen: SW antwortet offline mit 503+JSON → json() wirft nicht, d.url wäre undefined
+ if (!r.ok) throw new Error(`upload ${r.status}`);
+ return r.json();
+ }).then(d => { uploadedMediaUrl = d.url; })
+ .catch(() => UI.toast.error('Medien-Upload fehlgeschlagen.'));
};
reader.readAsDataURL(file);
}
diff --git a/backend/static/landing.html b/backend/static/landing.html
index ddc5d3d..c662fdf 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 c168f51..c0390b0 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 = '1220';
+const VER = '1228';
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/docker-compose.osm.yml b/docker-compose.osm.yml
index c786098..e496720 100644
--- a/docker-compose.osm.yml
+++ b/docker-compose.osm.yml
@@ -12,5 +12,8 @@ services:
- ./data:/data # gleiche DB wie die App (/data/banyaro.db)
environment:
- DB_PATH=/data/banyaro.db
- # - COUNTRIES=switzerland austria germany # bei Bedarf überschreiben
+ # Abdeckung = TILES_REGIONS im Makefile — Karten- und POI-Abdeckung
+ # synchron halten! Env überschreibt den Default in refresh.sh, daher
+ # wirkt eine Änderung hier OHNE Image-Rebuild.
+ - COUNTRIES=germany austria switzerland france italy czech-republic poland slovakia hungary slovenia netherlands belgium luxembourg denmark liechtenstein
restart: "no"
diff --git a/docs/OFFLINE_MAPS_PLAN.md b/docs/OFFLINE_MAPS_PLAN.md
index 73135e3..c440341 100644
--- a/docs/OFFLINE_MAPS_PLAN.md
+++ b/docs/OFFLINE_MAPS_PLAN.md
@@ -1,28 +1,83 @@
# Offline-Karten (GL/Vektor) — Feature-Plan
-**Status:** KERN UMGESETZT + headless verifiziert (2026-06-05, v1213), **flag-gated `by_offline_tiles` (Default AUS)** bis Gerätetest.
-**Stand:** 2026-06-05. Autor: René + Claude (Design).
+**Status:** LIVE auf Production + Staging (Default AN auf banyaro.app/.de, Prod-Freigabe René 2026-06-07
+nach bestandenen Gerätetests Runde 1+2). localhost = Leaflet/AUS.
+**Stand:** 2026-06-07. Autor: René + Claude (Design).
-## Umsetzungsstand (2026-06-05)
-**✅ Fertig + headless bewiesen:**
+## Umsetzungsstand (2026-06-06, v1222 auf Staging)
+**✅ Fertig + headless bewiesen (2026-06-05, v1213):**
- `map-offline.js` (`window.MapOffline`): Region-Download (`downloadAround(lat,lon,radiusKm)`) → Vektorkacheln
z0–14 via `pmtiles.getZxy` (liefert bereits dekomprimierte MVT) + Glyphs in **IndexedDB** (`by-offline-tiles`).
`byt://`-MapLibre-Protokoll (IndexedDB-first, remote-Fallback). ~15 MB / 5 km (dekomprimiert).
-- `map-gl-style.js` `build({offline})`: `byt`-Source statt `pmtiles://`. Flag `by_offline_tiles` (Default AUS).
+- `map-gl-style.js` `build({offline})`: `byt`-Source statt `pmtiles://`.
- ui.js/map.js laden map-offline + registrieren `byt`. `UI.loadMapLibreUI` exportiert.
- Welten-FAB Segment 5: prüft im GL-Modus gespeicherte Region (nicht mehr OSM-Raster); „Fehlende nachladen"
stößt `MapOffline.downloadAround(GPS, 5km)` an.
- **Beweis:** Download 97 Tiles (5 km München) → Netz AUS → **1903 Features gerendert**, nicht geladene
Gegend (Paris) leer; Glyphs nötig (sonst lässt MapLibre offline die ganze Kachel fallen).
-**🔲 Offen (Follow-ups):**
-- **Gerätetest (iOS-PWA offline/IndexedDB)** → dann Flag-Default auf Staging-AN (analog `by_map_gl`).
-- Download-Button auf der **Karte** (`map-offline-btn`) im GL-Modus auf `downloadAround(Karten-Center)` umbiegen
- (bisher OSM-Raster-Prefetch).
-- **Adaptives Lernen** (rollendes Vorausladen beim Aufzeichnen + Funkloch-Gedächtnis).
-- **Bereichsauswahl / Routen-Korridor** (inkl. „Route offline speichern" aus routes.js `_openDetail`).
-- **Glyph-Persistenz** über App-Updates (aktuell SW-Cache, wird bei Update gepurged) → in IndexedDB ablegen + via `byt://f/` servieren.
-- Alten OSM-Raster-Prefetch (`offline-indicator.js _prefetchTiles`) entfernen, wenn Flag dauerhaft AN.
+**✅ Follow-ups Runde 1 (2026-06-06, v1222):**
+- **Flag-Default Staging-AN:** `by_offline_tiles` Default AN auf `staging.banyaro.app`, AUS sonst;
+ localStorage `1`/`0` bzw. `?tilesoffline=1/0` (boot.js) übersteuert. Default-Logik 3× synchron:
+ `map-gl-style.js _offlineEnabled()`, `offline-indicator.js _offlineTilesMode()`, `pages/map.js _offlineTilesEnabled()`.
+- **Karten-Download-Button:** Speed-Dial „Karte offline speichern" (`map-offline-btn`, war seit FAB-Redesign
+ verwaist) — GL-Modus → `downloadAround(Kartenmitte, 5 km)` mit Fortschritt in der Statusbar (Kartenmitte
+ statt GPS: Urlaubsort vorab speicherbar); Leaflet-Modus → alter Raster-Prefetch (`_cacheTiles`).
+ Sichtbarkeit gated: GL ohne Offline-Flag (= Production) zeigt den Button nicht.
+- **Glyph-Persistenz:** Glyphs in IndexedDB (Key-Präfix `f/` im Tiles-Store, kein Schema-Bump) + Protokoll
+ `byt://f/{fontstack}/{range}` (IndexedDB-first, remote-Fallback); Style nutzt offline die byt-Glyph-URL
+ → überlebt App-Updates (SW-Cache wird gepurged, IndexedDB nicht).
+- **Raster-Prefetch gegated:** `offline-indicator.js init()` überspringt `_prefetchTiles()` im
+ Offline-Tiles-Modus (GL nutzt das OSM-Raster nicht).
+
+**✅ Gerätetest-Befunde behoben (2026-06-06, v1223) — Gerätetest iOS BESTANDEN (Basemap+Labels offline ok):**
+- **POI-Marker offline:** `downloadAround` speichert zusätzlich `/api/osm/pois` (fast=true, liest lokale
+ osm_pois-DB) je Typ für die Region-Bbox in IndexedDB (Key-Präfix `p/
`, Merge per id — zweite
+ Region löscht die erste nicht). `MapOffline.pois(type, bbox)` filtert für den Ausschnitt; map.js
+ Phase-1-Catch fällt offline darauf zurück. POI-Typen-Liste in map-offline.js synchron mit
+ `OSM_LAYER_MAP` (pages/map.js) halten! Marker erscheinen erst nach ERNEUTEM Region-Download.
+- **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.
+
+**✅ 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.
+
+**✅ Gerätetest-Befunde Runde 2 behoben (v1227):**
+- **Giftköder + vermisste Hunde offline sichtbar** (René: „müssen unbedingt sichtbar sein"):
+ Region-Download speichert zusätzlich `/api/poison` + `/api/lost` der Gegend (`p/_poison`,
+ `p/_lost`; Reader `MapOffline.alerts(kind, bbox)`). map.js `_loadAll` fällt **pro Quelle**
+ (nicht alles-oder-nichts) auf localStorage zurück — vorher verhinderte das SW-gecachte
+ `/api/places` den Fallback, während die Bbox-URL `/api/poison?lat=…` scheiterte.
+ lost.js merged den Region-Snapshot in beiden Offline-Pfaden.
+- **Korridor „unsichtbar"**: Logik war korrekt (Node-Stub-Test `downloadCorridor`/`coverage`
+ bestanden) — er lag im bereits gespeicherten Gebiet. Nach dem Speichern werden die
+ gespeicherten Bereiche jetzt blau auf der Routen-Detailkarte eingeblendet (`_detailMap._gl`).
+
+**🔲 Offen (Runde 3):**
+- **Gerätetest Runde 2** (Budget-Download, Funkloch-Lernen auf echter Gassi-Runde, Korridor,
+ Coverage-Layer) → dann Prod-Freigabe-Entscheidung (BY.offlineTiles-Default erweitern analog `by_map_gl`).
+- **Rollendes Vorausladen beim Aufzeichnen** (fortlaufend um die aktuelle Position cachen, solange
+ Empfang da — deckt den Weg schon beim ersten Mal ab; Akku-/Datensparsamkeit beachten).
+- **Bereichsauswahl** (Karten-Ausschnitt/Rechteck als Download-Gebiet) — Korridor deckt den
+ Hauptfall ab, Rest nach Bedarf.
+- **Speicher-Cap + LRU** über alles (alte Gebiete fliegen automatisch raus) + optional
+ `navigator.storage.persist()`.
+- Alten OSM-Raster-Prefetch (`offline-indicator.js _prefetchTiles` + `map.js _cacheTiles`) komplett
+ entfernen, wenn Flag dauerhaft AN (auch Prod).
## Ziel
GL-Vektorkarten offline-tauglich machen — Kernszenario **Gassi/Wandern im Funkloch**.
diff --git a/tools/osm-extract/refresh.sh b/tools/osm-extract/refresh.sh
index 903ed27..a2f9ec9 100644
--- a/tools/osm-extract/refresh.sh
+++ b/tools/osm-extract/refresh.sh
@@ -11,7 +11,9 @@ set -euo pipefail
DB="${DB_PATH:-/data/banyaro.db}"
WORK="${WORK_DIR:-/work}"
-COUNTRIES="${COUNTRIES:-switzerland austria germany}"
+# Default = TILES_REGIONS im Makefile (Karten- und POI-Abdeckung synchron halten).
+# Produktiv setzt docker-compose.osm.yml die Liste zusätzlich per Env.
+COUNTRIES="${COUNTRIES:-germany austria switzerland france italy czech-republic poland slovakia hungary slovenia netherlands belgium luxembourg denmark liechtenstein}"
GEOFABRIK="${GEOFABRIK_BASE:-https://download.geofabrik.de/europe}"
KEEP_BACKUPS="${KEEP_BACKUPS:-3}"
PREBUILT_SQLITE="${PREBUILT_SQLITE:-}"