diff --git a/VERSION b/VERSION index 1fdcf1c..c31e0ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1228 \ No newline at end of file +1229 \ No newline at end of file diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 5b2a7dd..c74c888 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7307,12 +7307,27 @@ svg.empty-state-icon { 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). */ +/* Eingeklappt (5s nach Offline-Gang, boot.js): kleines pulsierendes Icon oben rechts — + die Leiste über die volle Breite verdeckte Nav-Elemente (z.B. „← Zurück" in der + Routennavigation, Gerätetest 2026-06-07). Sitzt UNTERHALB der Kopfzeilen-Höhe, + damit es Buttons (Zentrieren, Legende) nie überlagert. */ #offline-banner.collapsed { - padding: calc(env(safe-area-inset-top, 0px) + 2px) 16px 2px; + top: calc(env(safe-area-inset-top, 0px) + 54px); + left: auto; + right: 8px; + width: 32px; + height: 32px; + padding: 0; + border-radius: 50%; + box-shadow: 0 2px 10px rgba(0,0,0,.35); + animation: by-offline-pulse 2s ease-in-out infinite; +} +#offline-banner.collapsed #offline-banner-text { display: none; } +#offline-banner.collapsed #offline-queue-badge { display: none !important; } +@keyframes by-offline-pulse { + 0%, 100% { opacity: 0.55; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.1); } } -#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 ab2e972..1e0e49a 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 468016a..41835b0 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 = '1228'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1229'; // ← 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/map-offline.js b/backend/static/js/map-offline.js index 127c286..92d6e88 100644 --- a/backend/static/js/map-offline.js +++ b/backend/static/js/map-offline.js @@ -234,6 +234,7 @@ window.MapOffline = (function () { function downloadAround(lat, lon, opts) { if (typeof opts === 'number') opts = {}; // alte Signatur (lat, lon, radiusKm) → Default-Budget opts = opts || {}; + _persistStorage(); var budget = (opts.budgetMB || 5) * 1048576; var maxKm = opts.maxRadiusKm || 25; var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM); @@ -293,6 +294,7 @@ window.MapOffline = (function () { return _addRegion({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: state.stored, bytes: state.bytes, pois: poiCount, savedAt: Date.now() }); }) + .then(function () { return _bumpTotal(state.bytes); }) .then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, radiusKm: radiusKm }; }); } @@ -301,7 +303,73 @@ window.MapOffline = (function () { // 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 + + // ---- Speicher-Cap (Soft-Guard für die AUTOMATISCHEN Pfade) ------------------- + // Manuelle Downloads bleiben immer möglich; Vorausladen + Funkloch-Autofill stoppen + // über dem Cap. totalBytes wird bei jedem Download mitgezählt; clear() setzt zurück. + var CAP_MB = 250; + function _bumpTotal(bytes) { + if (!bytes) return Promise.resolve(); + return _metaGet('totalBytes') + .then(function (t) { return _metaPut('totalBytes', (t || 0) + bytes); }) + .catch(function () {}); + } + function _overCap() { + return _metaGet('totalBytes') + .then(function (t) { return (t || 0) > CAP_MB * 1048576; }) + .catch(function () { return false; }); + } + + // Persistenten Speicher anfragen (best-effort, idempotent) — härtet IndexedDB gegen + // Eviction bei Speicherdruck. Safari/iOS ignoriert es teils, schadet aber nicht. + function _persistStorage() { + if (_persistStorage._done) return; + _persistStorage._done = true; + try { + if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(function () {}); + } catch (e) {} + } + + // {lat,lon} während aktiver Aufzeichnung, sonst null. Nebeneffekt (Runde 3): + // ROLLENDES VORAUSLADEN — solange Empfang da ist, alle ~400 m die fehlenden Kacheln + // um die aktuelle Position still mitnehmen. Deckt den Weg + die Anfahrt ab, BEVOR + // man ins Funkloch läuft (greift schon beim ersten Besuch, anders als das Gedächtnis). + var _lastPre = null, _preActive = false; + function setGps(pos) { + _gps = pos; + if (!pos) { _lastPre = null; return; } + if (_preActive || !navigator.onLine) return; + if (_lastPre && _distKm(_lastPre.lat, _lastPre.lon, pos.lat, pos.lon) < 0.4) return; + _preActive = true; + var p = { lat: pos.lat, lon: pos.lon }; + _overCap().then(function (over) { + if (over) return; + return _prefetchRing(p.lat, p.lon, 2).then(function () { _lastPre = p; }); + }).catch(function () {}) + .then(function () { _preActive = false; }); + } + + // z14-Kacheln ±n um lat/lon (+ Eltern z10–13) — NUR fehlende, still, ohne Region-Eintrag. + function _prefetchRing(lat, lon, n) { + var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM), seen = {}, list = []; + for (var x = cx - n; x <= cx + n; x++) for (var y = cy - n; y <= cy + n; y++) { + list.push([MAXZOOM, x, y]); + for (var pz = 13; pz >= 10; pz--) { + var px = x >> (MAXZOOM - pz), py = y >> (MAXZOOM - pz), k = pz + '/' + px + '/' + py; + if (!seen[k]) { seen[k] = 1; list.push([pz, px, py]); } + } + } + var missing = [], chain = Promise.resolve(); + list.forEach(function (t) { + chain = chain.then(function () { + return _get(t[0] + '/' + t[1] + '/' + t[2]).then(function (hit) { if (!hit) missing.push(t); }); + }); + }); + var state = { done: 0, bytes: 0, stored: 0 }; + return chain + .then(function () { return missing.length ? _fetchTiles(missing, state, null) : null; }) + .then(function () { return _bumpTotal(state.bytes); }); + } function _distKm(aLat, aLon, bLat, bLon) { var dLat = (bLat - aLat) * 111, dLon = (bLon - aLon) * 111 * Math.cos(aLat * Math.PI / 180); @@ -335,7 +403,11 @@ window.MapOffline = (function () { if (_autofillActive || !navigator.onLine) return Promise.resolve(0); _autofillActive = true; var filled = 0; - return _metaGet('deadzones').then(function (zones) { + return _overCap().then(function (over) { + if (over) return null; // Speicher-Cap erreicht → kein automatisches Nachladen mehr + return _metaGet('deadzones'); + }).then(function (zones) { + if (zones === null) return 0; zones = zones || []; var open = zones.filter(function (z) { return !z.filled; }); if (!open.length) return 0; @@ -360,6 +432,7 @@ window.MapOffline = (function () { function downloadCorridor(track, opts) { opts = opts || {}; if (!track || track.length < 2) return Promise.reject(new Error('Kein GPS-Track')); + _persistStorage(); var buffer = opts.bufferKm || 1, cap = (opts.capMB || 50) * 1048576; var seen = {}, list = []; var push = function (z, x, y) { @@ -403,6 +476,47 @@ window.MapOffline = (function () { 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 _bumpTotal(state.bytes); }) + .then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; }); + } + + // ---- Bereichsauswahl: sichtbaren Karten-Ausschnitt komplett speichern --------- + // bbox = {south,west,north,east} (z.B. aktueller Viewport). Zu-groß-Schutz über + // Kachelzahl, Abbruch-Cap über capMB. opts {capMB:40, name, onProgress({bytes,done,total})}. + function downloadBbox(bbox, opts) { + opts = opts || {}; + _persistStorage(); + var cap = (opts.capMB || 40) * 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]); } + }; + for (var z = 0; z <= MAXZOOM; z++) { + var x0 = _x(bbox.west, z), x1 = _x(bbox.east, z), y0 = _y(bbox.north, z), y1 = _y(bbox.south, z); + if (z === MAXZOOM && (x1 - x0 + 1) * (y1 - y0 + 1) > 4000) { + return Promise.reject(new Error('Bereich zu groß — bitte weiter reinzoomen.')); + } + for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) push(z, x, y); + } + var state = { done: 0, bytes: 0, stored: 0 }, total = list.length, poiCount = 0; + 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); }); + } + var midLat = (bbox.south + bbox.north) / 2, midLon = (bbox.west + bbox.east) / 2; + return chunkLoop(0) + .then(function () { return _cacheGlyphs(); }) + .then(function (gb) { state.bytes += gb; return _cachePois(bbox); }) + .then(function (pc) { + poiCount = pc; + return _addRegion({ type: 'ausschnitt', name: opts.name || null, lat: midLat, lon: midLon, + tiles: state.stored, bytes: state.bytes, pois: poiCount, savedAt: Date.now() }); + }) + .then(function () { return _bumpTotal(state.bytes); }) .then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; }); } @@ -432,8 +546,10 @@ window.MapOffline = (function () { function stats() { return _count().then(function (count) { return _metaGet('regions').then(function (regions) { - return _metaGet('region').then(function (meta) { - return { count: count, meta: meta || null, regions: regions || [] }; + return _metaGet('totalBytes').then(function (totalBytes) { + return _metaGet('region').then(function (meta) { + return { count: count, meta: meta || null, regions: regions || [], totalBytes: totalBytes || 0 }; + }); }); }); }); @@ -446,7 +562,7 @@ window.MapOffline = (function () { return { registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor, - tile: tile, glyph: glyph, pois: pois, alerts: alerts, coverage: coverage, + downloadBbox: downloadBbox, 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 cb6bf6e..54a06be 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -417,9 +417,9 @@ window.OfflineIndicator = (() => { function init() { refresh(); _prefetchPages(); - // 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(); + // Automatischer OSM-Raster-Prefetch ENTFERNT (2026-06-07): Flag ist auf allen deployten + // Hosts AN, die GL-Karte nutzt das Raster nicht. _prefetchTiles bleibt nur noch für den + // manuellen „Fehlende nachladen"-Pfad im Leaflet-Modus (localhost / by_map_gl=0). _prefetchData(); _bindLongPress(); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index e62f653..e179322 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -2190,6 +2190,34 @@ window.Page_map = (() => { } } + // Bereichsauswahl: den SICHTBAREN Karten-Ausschnitt komplett speichern (z.B. fürs + // Urlaubsziel: hinzoomen/-schieben, speichern). Cap 40 MB, Zu-groß-Schutz in MapOffline. + async function _downloadViewport() { + if (!_map || !window.MapOffline) return; + const btn = document.getElementById('map-offline-btn'); + if (btn?.classList.contains('loading')) return; + const p = _mapPaddedBounds(0.02); + btn?.classList.add('loading'); + _setOsmStatus('Offline: 0 MB…'); + try { + const res = await MapOffline.downloadBbox( + { south: p.south, west: p.west, north: p.north, east: p.east }, + { capMB: 40, onProgress: pr => { + _setOsmStatus(`Offline: ${(pr.bytes / 1048576).toFixed(1)} MB (${Math.round(pr.done / pr.total * 100)} %)…`); + } }); + _setOsmStatus(''); + UI.toast.success(`Ausschnitt offline gespeichert — ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.` + + `${res.capped ? ' (40-MB-Limit erreicht)' : ''}`); + window.OfflineIndicator?.refresh(); + if (_covOn) _setCoverage(true); + } catch (e) { + _setOsmStatus(''); + UI.toast.error(e?.message?.includes('zu groß') ? e.message : 'Offline-Download fehlgeschlagen — bitte erneut versuchen.'); + } finally { + btn?.classList.remove('loading'); + } + } + // ---------------------------------------------------------- // Offline-Bereiche-Layer (gespeicherte z14-Kacheln) + Verwaltungs-Modal // ---------------------------------------------------------- @@ -2226,7 +2254,7 @@ window.Page_map = (() => { 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 totalBytes = s.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', @@ -2238,6 +2266,7 @@ window.Page_map = (() => {

+ ${regions.length ? `` : ''}
@@ -2245,6 +2274,7 @@ window.Page_map = (() => { footer: ``, }); document.getElementById('off-dl')?.addEventListener('click', () => { UI.modal.close(); _downloadVectorRegion(); }); + document.getElementById('off-bbox')?.addEventListener('click', () => { UI.modal.close(); _downloadViewport(); }); 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; diff --git a/backend/static/landing.html b/backend/static/landing.html index c662fdf..d2f572d 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 c0390b0..27b8baf 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 = '1228'; +const VER = '1229'; 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 c440341..d7e7af1 100644 --- a/docs/OFFLINE_MAPS_PLAN.md +++ b/docs/OFFLINE_MAPS_PLAN.md @@ -67,17 +67,30 @@ nach bestandenen Gerätetests Runde 1+2). localhost = Leaflet/AUS. 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). +**✅ Runde 3 (2026-06-07):** +- **Offline-Indikator = pulsierendes Icon** oben rechts (32 px, unterhalb der Kopfzeilen-Höhe) statt + Banner über die volle Breite — verdeckte Nav-Elemente, z.B. „← Zurück" in der Routennavigation + (Gerätetest René). Vollbanner weiterhin 5 s beim Offline-Gang, dann Icon. +- **Rollendes Vorausladen beim Aufzeichnen:** `setGps()` lädt alle ~400 m still die FEHLENDEN + z14±2-Kacheln (+Eltern) um die Position, solange online — deckt Weg + Anfahrt schon beim ERSTEN + Besuch ab (das Funkloch-Gedächtnis greift erst ab dem 2.). Kein Region-Eintrag, kein UI. +- **Bereichsauswahl light:** Modal-Button „Sichtbaren Ausschnitt speichern" → `downloadBbox(viewport, + {capMB:40})` mit Zu-groß-Schutz (>4000 z14-Kacheln → „bitte reinzoomen"). +- **Speicher-Cap 250 MB (Soft-Guard):** `totalBytes`-Zähler in Meta; AUTOMATISCHE Pfade (Vorausladen, + Funkloch-Autofill) stoppen über dem Cap, manuelle bleiben; `navigator.storage.persist()` best-effort. + Echte LRU-Eviction bewusst vertagt (Kacheln werden regionsübergreifend geteilt → Eviction braucht + Refcounting; bei ~8 MB/Gebiet kein Druck). +- **Auto-OSM-Raster-Prefetch entfernt** (offline-indicator init); `_prefetchTiles`/`_cacheTiles` + bleiben nur für den manuellen Leaflet-Pfad (localhost / by_map_gl=0). +- Logik per Node-Stub-Tests verifiziert (Bbox, Zu-groß, Cap, Prefetch-Throttle, persist). + Achtung Node 21+: eingebautes `navigator`-Global schluckt `global.navigator=`-Stubs — + `Object.defineProperty(globalThis, 'navigator', …)` verwenden. + +**🔲 Offen (Backlog):** +- Echte LRU-Eviction (Refcounting/Region-Zuordnung der Kacheln), wenn Nutzer real ans Cap kommen. +- Rechteck-Zeichnen als präzisere Bereichsauswahl (Viewport-Variante deckt den Hauptfall ab). +- POIs auch beim rollenden Vorausladen (aktuell nur Kacheln; Giftköder kommen aus dem + localStorage-Fallback der letzten Online-Position). ## Ziel GL-Vektorkarten offline-tauglich machen — Kernszenario **Gassi/Wandern im Funkloch**.