From 42a04ec405302a61a521dc51c8b2744b62aeccd1 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 6 Jun 2026 12:00:43 +0200 Subject: [PATCH] Offline-Karten Runde 2: adaptives Modell (Budget, Funkloch-Gedaechtnis, Korridor, Coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design Rene 2026-06-06: - Budget-Download: z14-Ringe um den Standort bis 5 MB gespeicherte Bytes (Stadt klein, Land gross — passend zur Funknetzdichte); client-seitig, Server-Region-Extract entfaellt - Funkloch-Gedaechtnis: Tile-Miss bei aktivem GPS-Recording -> Zone gemerkt (lokal, nie hochgeladen); Auto-Download offener Zonen sobald online - Routen-Korridor: 'Offline'-Button im Routen-Detail, Kacheln +-1km um den Track + Marker (Cap 50 MB) — fuer mehrtaegige Unternehmungen - Coverage-Layer: gespeicherte Bereiche als blauer Layer; Offline-Button oeffnet Verwaltungs-Modal (Stats, speichern, anzeigen, loeschen) - Flag-Logik zentral in boot.js BY.offlineTiles() (war 3x dupliziert) Bump v1226 --- VERSION | 2 +- backend/static/index.html | 24 +-- backend/static/js/app.js | 2 +- backend/static/js/boot.js | 18 +- backend/static/js/map-gl-style.js | 11 +- backend/static/js/map-offline.js | 270 ++++++++++++++++++++++--- backend/static/js/offline-indicator.js | 60 +++++- backend/static/js/pages/map.js | 105 ++++++++-- backend/static/js/pages/routes.js | 26 +++ backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- docs/OFFLINE_MAPS_PLAN.md | 35 +++- 12 files changed, 466 insertions(+), 91 deletions(-) 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).