Offline-Karten: Kern implementiert (Region-Download → IndexedDB → Offline-Render)
map-offline.js (window.MapOffline): lädt Vektorkacheln eines Bereichs via pmtiles.getZxy
in IndexedDB + cacht die Glyphs mit (KRITISCH: ohne Glyphs lässt MapLibre offline die
ganze Kachel fallen). byt://-Protokoll bedient MapLibre IndexedDB-first, remote-Fallback.
- map-gl-style.js: build({offline}) nutzt byt-Source statt pmtiles:// (Flag by_offline_tiles,
Default AUS bis gerätegetestet); glyphs bleiben /fonts (SW-gecacht)
- ui.js + map.js: map-offline.js mitladen + byt-Protokoll registrieren
- getZxy liefert bereits dekomprimierte MVT (kein gunzip) → ~15 MB/5km in IndexedDB
Headless bewiesen: Download 97 Tiles (5km München) → Netz AUS → 1903 Features gerendert,
nicht geladene Gegend (Paris) korrekt leer. Offen: Download-Button/FAB-Segment-5-Verdrahtung,
adaptives Lernen, Bereichsauswahl/Routen-Korridor (siehe docs/OFFLINE_MAPS_PLAN.md).
This commit is contained in:
parent
2a809a9a0b
commit
8f13f4d38d
9 changed files with 177 additions and 20 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1208
|
||||
1211
|
||||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1208"></script>
|
||||
<script src="/js/boot-early.js?v=1211"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1208">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1208">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1208">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1208">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1208">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1211">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1211">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1211">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1211">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1211">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -617,11 +617,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1208"></script>
|
||||
<script src="/js/ui.js?v=1208"></script>
|
||||
<script src="/js/app.js?v=1208"></script>
|
||||
<script src="/js/worlds.js?v=1208"></script>
|
||||
<script src="/js/offline-indicator.js?v=1208"></script>
|
||||
<script src="/js/api.js?v=1211"></script>
|
||||
<script src="/js/ui.js?v=1211"></script>
|
||||
<script src="/js/app.js?v=1211"></script>
|
||||
<script src="/js/worlds.js?v=1211"></script>
|
||||
<script src="/js/offline-indicator.js?v=1211"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1208"></script>
|
||||
<script src="/js/boot.js?v=1211"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
141
backend/static/js/map-offline.js
Normal file
141
backend/static/js/map-offline.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1208"></script>
|
||||
<script src="/js/landing-init.js?v=1211"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue