diff --git a/VERSION b/VERSION index 24d3bec..02be51a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1208 \ No newline at end of file +1211 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 3fa21ed..5987a83 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d43b455..55a367c 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 = '1208'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1211'; // ← 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-gl-style.js b/backend/static/js/map-gl-style.js index ac14f95..9a02bc9 100644 --- a/backend/static/js/map-gl-style.js +++ b/backend/static/js/map-gl-style.js @@ -12,6 +12,12 @@ 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). + function _offlineEnabled() { + try { return localStorage.getItem('by_offline_tiles') === '1'; } catch (e) { return false; } + } + var THEMES = { light: { bg: '#f2efe8', land: '#cbe3a8', park: '#aedd88', water: '#7fbbe8', @@ -38,11 +44,17 @@ function build(opts) { opts = opts || {}; var t = THEMES[opts.dark ? 'dark' : 'light']; + // offline → Tiles übers byt://-Protokoll (IndexedDB-first, remote-Fallback) statt direkt aus der + // Remote-PMTiles. Nötig für Offline-Betrieb. Default aus Flag (by_offline_tiles), explizit übersteuerbar. + var useOffline = opts.offline != null ? opts.offline : _offlineEnabled(); + var src = useOffline + ? { type: 'vector', tiles: ['byt://t/{z}/{x}/{y}'], minzoom: 0, maxzoom: 14 } + : { type: 'vector', url: 'pmtiles://' + tilesUrl() }; return { version: 8, glyphs: window.location.origin + '/fonts/{fontstack}/{range}.pbf', sources: { - by: { type: 'vector', url: 'pmtiles://' + tilesUrl() }, + by: src, }, layers: [ { id: 'bg', type: 'background', paint: { 'background-color': t.bg } }, @@ -160,5 +172,5 @@ setTimeout(fn, 60); } - window.MapGLStyle = { build: build, tilesUrl: tilesUrl, tilesFile: TILES_FILE, collapseAttribution: collapseAttribution }; + window.MapGLStyle = { build: build, tilesUrl: tilesUrl, tilesFile: TILES_FILE, collapseAttribution: collapseAttribution, offlineEnabled: _offlineEnabled }; })(); diff --git a/backend/static/js/map-offline.js b/backend/static/js/map-offline.js new file mode 100644 index 0000000..76741ee --- /dev/null +++ b/backend/static/js/map-offline.js @@ -0,0 +1,141 @@ +/* ============================================================ + BAN YARO — Offline-Vektorkacheln + Lädt einen Bereich aus der Remote-PMTiles (dach.pmtiles) als einzelne MVT-Tiles + in IndexedDB und bedient MapLibre offline daraus über das `byt://`-Protokoll. + Plan/Architektur: docs/OFFLINE_MAPS_PLAN.md + ============================================================ */ +window.MapOffline = (function () { + 'use strict'; + + var DB_NAME = 'by-offline-tiles', STORE = 'tiles', META = 'meta', DB_VER = 1; + var MAXZOOM = 14; // unsere pmtiles enden bei z14 (Overzoom darüber) + var _db = null, _pm = null; + // Hinweis: pmtiles.getZxy() liefert die Tiles BEREITS dekomprimiert (rohe MVT-Protobufs) → + // wir speichern/servieren sie direkt, kein gunzip. Dadurch ist die IndexedDB-Größe ~2,5× die + // komprimierte pmtiles-Extract-Größe (5 km ≈ ~16 MB statt 6,4 MB) — fürs Handy unkritisch. + + // ---- IndexedDB ---- + function _open() { + if (_db) return Promise.resolve(_db); + return new Promise(function (res, rej) { + var r = indexedDB.open(DB_NAME, DB_VER); + r.onupgradeneeded = function () { + var d = r.result; + if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE); + if (!d.objectStoreNames.contains(META)) d.createObjectStore(META); + }; + r.onsuccess = function () { _db = r.result; res(_db); }; + r.onerror = function () { rej(r.error); }; + }); + } + function _req(store, mode, make) { + return _open().then(function (d) { return new Promise(function (res, rej) { + var tx = d.transaction(store, mode), rq = make(tx.objectStore(store)); + tx.oncomplete = function () { res(rq ? rq.result : undefined); }; + tx.onerror = function () { rej(tx.error); }; + }); }); + } + 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(); }); }; + + // ---- Remote-PMTiles ---- + function _pmInst() { if (!_pm) _pm = new pmtiles.PMTiles(MapGLStyle.tilesUrl()); return _pm; } + + // MVT-Bytes (Uint8Array) für z/x/y — IndexedDB zuerst, sonst remote (online), sonst null. + function tile(z, x, y) { + return _get(z + '/' + x + '/' + y).then(function (hit) { + 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 + }); + } + + // MapLibre-Protokoll `byt://t/{z}/{x}/{y}` registrieren (idempotent). + 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) { + if (!u) return { data: new ArrayBuffer(0) }; + return { data: u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) }; + }); + }); + } + + // ---- Slippy-Tile-Mathe ---- + function _x(lon, z) { return Math.floor((lon + 180) / 360 * Math.pow(2, z)); } + function _y(lat, z) { + 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) { + 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; + } + + // Glyphs (Open Sans Regular/Semibold, Latin + Latin-Extended) holen, damit der Service-Worker sie cacht. + // 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.) + var FONTS = ['Open Sans Regular', 'Open Sans Semibold'], RANGES = ['0-255', '256-511']; + function _cacheGlyphs() { + var bytes = 0, jobs = []; + FONTS.forEach(function (f) { RANGES.forEach(function (rg) { + jobs.push(fetch('/fonts/' + encodeURIComponent(f) + '/' + rg + '.pbf') + .then(function (r) { return r.ok ? r.arrayBuffer() : null; }) + .then(function (b) { if (b) bytes += b.byteLength; }) + .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; + function next() { + if (i >= total) 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); } + }).catch(function () {}).then(function () { + done++; + if (onProgress && (done % 8 === 0 || done === total)) onProgress({ done: done, total: total, bytes: bytes }); + 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 }; }); + } + + 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 }; }); + }); + } + function hasRegion() { return stats().then(function (s) { return s.count > 0; }).catch(function () { return false; }); } + function clear() { + return _req(STORE, 'readwrite', function (os) { os.clear(); }) + .then(function () { return _req(META, 'readwrite', function (os) { os.clear(); }); }); + } + + return { + registerProtocol: registerProtocol, downloadAround: downloadAround, tile: tile, + stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM, + }; +})(); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 2e0e026..9517e45 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -678,16 +678,18 @@ window.Page_map = (() => { if ((src.includes('maplibre-gl.js') && window.maplibregl) || (src.includes('pmtiles.js') && window.pmtiles) || (src.includes('map-gl-style') && window.MapGLStyle) || + (src.includes('map-offline') && window.MapOffline) || (src.includes('map-gl-markers') && window.MapGLMarkers)) return res(); const s = document.createElement('script'); s.src = src + v; s.onload = res; s.onerror = rej; document.head.appendChild(s); })), Promise.resolve()); - return seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-gl-markers.js']).then(() => { + return seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-offline.js', '/js/map-gl-markers.js']).then(() => { if (!(window.maplibregl && window.pmtiles && window.MapGLStyle && window.MapGLMarkers)) throw new Error('MapLibre nicht geladen'); if (!_pmtilesProtoReg) { const proto = new pmtiles.Protocol(); maplibregl.addProtocol('pmtiles', proto.tile); + try { window.MapOffline && MapOffline.registerProtocol(); } catch (e) {} _pmtilesProtoReg = true; } _maplibreLoaded = true; diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 29d5268..23a71d5 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -922,14 +922,16 @@ const UI = (() => { if ((src.includes('maplibre-gl.js') && window.maplibregl) || (src.includes('pmtiles.js') && window.pmtiles) || (src.includes('map-gl-style') && window.MapGLStyle) || + (src.includes('map-offline') && window.MapOffline) || (src.includes('map-gl-mini') && window.MapGLMini)) return res(); const s = document.createElement('script'); s.src = src + v; s.onload = res; s.onerror = rej; document.head.appendChild(s); })), Promise.resolve()); - _maplibreUIPromise = seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-gl-mini.js']).then(() => { + _maplibreUIPromise = seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-offline.js', '/js/map-gl-mini.js']).then(() => { if (!(window.maplibregl && window.pmtiles && window.MapGLStyle && window.MapGLMini)) throw new Error('MapLibre (UI) nicht geladen'); try { const proto = new pmtiles.Protocol(); maplibregl.addProtocol('pmtiles', proto.tile); } catch (e) { /* evtl. schon registriert */ } + try { window.MapOffline && MapOffline.registerProtocol(); } catch (e) { /* byt://-Protokoll für Offline-Tiles */ } }); return _maplibreUIPromise; } diff --git a/backend/static/landing.html b/backend/static/landing.html index 9af14db..2cbc2a4 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 a0316c9..b9fa861 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 = '1208'; +const VER = '1211'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten