- Indikator links unter die Zoom-Regler (rechts verdeckte Legenden-Chips) - Flugmodus bei offener App -> Position raw als Funkloch-Zone (offline-Event) - Ent-Funklochen: Zonen-Liste im Offline-Modal mit X (removeDeadZone) - Warnungs-Aktualitaet: _mergeStore Bbox-Replace (aufgehobene Giftkoeder/ gefundene Hunde verschwinden; Fetch-Kreis deckt Bbox via sqrt2 ab; fresh=null merged nie) + 24h-Refresh im 50km-Umkreis beim Start - Routen offline nutzbar halten: ensureRouteCorridors beim Start-Check (Stichproben-Verify, Re-Download aus preview_track, Region-Dedupe) - Stub-Tests ins Repo: tests/js/ (r1/r3/r4/r5, alle gruen) Bump v1231
536 lines
22 KiB
JavaScript
536 lines
22 KiB
JavaScript
/* ============================================================
|
|
BAN YARO — Offline-Bereitschafts-Anzeige IM Welten-FAB
|
|
Färbt die 5 Pfoten-Pfade je nach Cache-Stand grün:
|
|
1 = App-Shell · 2 = Wichtige Seiten · 3 = Hund-/Tagebuchdaten
|
|
4 = Karten-Tiles · 5 = Training & Wissen
|
|
============================================================ */
|
|
|
|
window.OfflineIndicator = (() => {
|
|
'use strict';
|
|
|
|
// Cache-Namen dynamisch finden — robust gegen Versions-Updates
|
|
const CACHE_TILES = 'ban-yaro-tiles-v1';
|
|
const CACHE_API = 'ban-yaro-api-v1';
|
|
const TILE_MIN = 20; // niedriger Schwellwert: 5x4 Tiles reichen für Nahbereich
|
|
const LS_LAST_POS = 'by_last_position'; // teilt sich Storage mit wetter.js
|
|
|
|
async function _staticCache() {
|
|
const names = await caches.keys();
|
|
const found = names.find(n => /^by-v\d+-static$/.test(n));
|
|
return found ? await caches.open(found) : null;
|
|
}
|
|
|
|
// GL-Offline-Tiles-Modus (byt://-Vektorkacheln in IndexedDB) statt OSM-Raster.
|
|
// Zentrale Flag-Logik in boot.js BY.offlineTiles().
|
|
function _offlineTilesMode() {
|
|
try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; }
|
|
}
|
|
// Offline-Ready (Pfote Segment 5) — ohne MapOffline/GL-Stack zu laden.
|
|
// Semantik (Modell René 2026-06-08): Gibt es bekannte FUNKLOCH-Zonen, zählt deren
|
|
// Füllstand (alle gefüllt = grün); ohne bekannte Zonen wie bisher: irgendein Gebiet da.
|
|
// WICHTIG: dasselbe Schema/Version wie map-offline.js anlegen — sonst legt ein versionsloses
|
|
// open() die DB leer an und MapOffline kann seine Stores nicht mehr erstellen.
|
|
function _offlineRegionStored() {
|
|
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('tiles') || !db.objectStoreNames.contains('meta')) {
|
|
db.close(); return res(false);
|
|
}
|
|
const mz = db.transaction('meta', 'readonly').objectStore('meta').get('deadzones');
|
|
mz.onsuccess = () => {
|
|
const zones = mz.result || [];
|
|
if (zones.length) { res(zones.every(z => z.filled)); db.close(); return; }
|
|
const cnt = db.transaction('tiles', 'readonly').objectStore('tiles').count();
|
|
cnt.onsuccess = () => { res(cnt.result > 0); db.close(); };
|
|
cnt.onerror = () => { res(false); db.close(); };
|
|
};
|
|
mz.onerror = () => { res(false); db.close(); };
|
|
};
|
|
r.onerror = () => res(false);
|
|
} catch (e) { res(false); }
|
|
});
|
|
}
|
|
|
|
const CHECKS = [
|
|
{ step: 1, title: 'App-Grundgerüst',
|
|
detail: 'CSS, Layout und Hauptmodule — die Basis',
|
|
probe: async () => {
|
|
const c = await _staticCache();
|
|
if (!c) return false;
|
|
const urls = (await c.keys()).map(r => r.url);
|
|
return urls.some(u => u.includes('/css/design-system.css'))
|
|
&& urls.some(u => u.includes('/js/app.js'));
|
|
} },
|
|
|
|
{ step: 2, title: 'Wichtige Seiten',
|
|
detail: 'Tagebuch, Gesundheit, Karte, Gassi, Erste Hilfe, Notizen, Ausgaben, Routen',
|
|
probe: async () => {
|
|
const c = await _staticCache();
|
|
if (!c) return false;
|
|
const want = ['diary.js','health.js','map.js','walks.js','erste-hilfe.js',
|
|
'notes.js','expenses.js','routes.js'];
|
|
const urls = (await c.keys()).map(r => r.url);
|
|
const have = want.filter(name => urls.some(u => u.includes('/js/pages/' + name)));
|
|
return have.length >= want.length - 1; // 1 Toleranz (falls einzelner Fetch fehlschlug)
|
|
} },
|
|
|
|
{ step: 3, title: 'Hund-Daten',
|
|
detail: 'Profil, Tagebuch und Gesundheit',
|
|
probe: async () => {
|
|
const c = await caches.open(CACHE_API).catch(() => null);
|
|
if (!c) return false;
|
|
const urls = (await c.keys()).map(r => r.url);
|
|
const hasProfile = urls.some(u => /\/api\/dogs\/\d+(\?|$)/.test(u))
|
|
|| urls.some(u => /\/api\/dogs\/\d+\/welcome-dashboard/.test(u));
|
|
const hasDiary = urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u));
|
|
const hasHealth = urls.some(u => /\/api\/dogs\/\d+\/health/.test(u));
|
|
// Profil + mindestens eine Datenquelle (Tagebuch oder Gesundheit)
|
|
return hasProfile && (hasDiary || hasHealth);
|
|
} },
|
|
|
|
{ step: 4, title: 'Weitere Listen',
|
|
detail: 'Ausgaben, Routen, Notizen',
|
|
probe: async () => {
|
|
const c = await caches.open(CACHE_API).catch(() => null);
|
|
if (!c) return false;
|
|
const urls = (await c.keys()).map(r => r.url);
|
|
return urls.some(u => u.includes('/api/expenses'))
|
|
&& urls.some(u => u.includes('/api/routes'))
|
|
&& urls.some(u => u.includes('/api/notes'));
|
|
} },
|
|
|
|
{ step: 5, title: 'Karten-Kacheln',
|
|
detail: 'Karten für deine Gegend offline verfügbar',
|
|
probe: async () => {
|
|
// GL-Modus: gespeicherte Vektor-Region in IndexedDB (das alte OSM-Raster nutzt die GL-Karte nicht).
|
|
if (_offlineTilesMode()) return _offlineRegionStored();
|
|
const c = await caches.open(CACHE_TILES).catch(() => null);
|
|
if (!c) return false;
|
|
return (await c.keys()).length >= TILE_MIN;
|
|
} },
|
|
];
|
|
|
|
// Tile-Prefetch-Konfiguration
|
|
const TILE_PREFETCH = [
|
|
{ zoom: 14, radius: 3 }, // 7x7 = 49 Tiles im Nahbereich
|
|
{ zoom: 13, radius: 1 }, // 3x3 = 9 Tiles für Übersicht
|
|
];
|
|
|
|
let _fab = null;
|
|
|
|
async function refresh() {
|
|
_fab = document.getElementById('worlds-fab');
|
|
if (!_fab || !('caches' in window)) return null;
|
|
|
|
const results = await Promise.all(CHECKS.map(async c => {
|
|
try { return { ...c, ok: await c.probe() }; }
|
|
catch { return { ...c, ok: false }; }
|
|
}));
|
|
|
|
_fab.querySelectorAll('.paw-elem').forEach(el => {
|
|
const step = Number(el.dataset.step);
|
|
const isOk = results.find(r => r.step === step)?.ok;
|
|
el.classList.toggle('filled', !!isOk);
|
|
});
|
|
|
|
const score = results.filter(r => r.ok).length;
|
|
_fab.setAttribute('data-offline-score', `${score}/5`);
|
|
return { score, results };
|
|
}
|
|
|
|
// Optional aufrufbar: zeigt das Status-Modal mit Nachlade-Button
|
|
async function openStatus() {
|
|
const data = await refresh();
|
|
if (!data) return;
|
|
const { score, results } = data;
|
|
const missing = results.filter(r => !r.ok);
|
|
const rows = results.map(r => `
|
|
<div class="offline-status-row ${r.ok ? 'ok' : 'miss'}">
|
|
<div class="osr-check">${r.ok ? '✓' : '○'}</div>
|
|
<div class="osr-text">
|
|
<div class="osr-title">${r.title}</div>
|
|
<div class="osr-detail">${r.detail}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
UI.modal.open({
|
|
title: `🐾 Offline-Bereitschaft ${score}/5`,
|
|
body: `
|
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
|
|
${missing.length === 0
|
|
? 'Voll offline-fähig — Tagebuch, Karte und Daten funktionieren auch ohne Internet.'
|
|
: 'Je grüner deine Pfote im FAB, desto mehr klappt offline. Fehlende Inhalte werden beim nächsten Online-Aufruf automatisch geladen.'}
|
|
</p>
|
|
${rows}
|
|
`,
|
|
footer: `
|
|
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
|
${missing.length ? `<button class="btn btn-primary" id="offline-fill-btn" style="width:100%">
|
|
${UI.icon('cloud-arrow-down')} Fehlende jetzt nachladen
|
|
</button>` : ''}
|
|
<button class="btn btn-secondary" data-modal-close style="width:100%">Schließen</button>
|
|
</div>
|
|
`,
|
|
});
|
|
|
|
document.getElementById('offline-fill-btn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('offline-fill-btn');
|
|
btn.disabled = true; btn.textContent = 'Lade …';
|
|
await _fetchMissing(missing);
|
|
UI.modal.close();
|
|
UI.toast.success('Offline-Inhalte aktualisiert.');
|
|
refresh();
|
|
});
|
|
}
|
|
|
|
async function _fetchMissing(missing) {
|
|
const tasks = [];
|
|
for (const m of missing) {
|
|
if (m.step === 2) {
|
|
['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js'].forEach(p =>
|
|
tasks.push(fetch(`/js/pages/${p}?v=${window.APP_VER}`).catch(() => {})));
|
|
} else if (m.step === 3) {
|
|
const dogId = window._appState?.activeDog?.id;
|
|
if (dogId) {
|
|
tasks.push(fetch(`/api/dogs/${dogId}`).catch(() => {}));
|
|
tasks.push(fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {}));
|
|
tasks.push(fetch(`/api/dogs/${dogId}/health`).catch(() => {}));
|
|
}
|
|
} else if (m.step === 4) {
|
|
tasks.push(fetch('/api/expenses').catch(() => {}));
|
|
tasks.push(fetch('/api/routes').catch(() => {}));
|
|
tasks.push(fetch('/api/notes').catch(() => {}));
|
|
} else if (m.step === 5) {
|
|
if (_offlineTilesMode()) await _downloadOfflineRegion();
|
|
else await _prefetchTiles();
|
|
}
|
|
}
|
|
await Promise.all(tasks);
|
|
}
|
|
|
|
// 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) {}
|
|
if (!pos) {
|
|
try {
|
|
const raw = localStorage.getItem(LS_LAST_POS);
|
|
if (raw) { const p = JSON.parse(raw); pos = { lat: p.lat, lon: p.lon }; }
|
|
} catch (e) {}
|
|
}
|
|
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, { budgetMB: 5 });
|
|
} catch (e) { console.warn('Offline-Region-Download fehlgeschlagen:', e); }
|
|
}
|
|
|
|
// Gibt es überhaupt Funkloch-Zonen? — direkt aus IndexedDB, OHNE den GL-Stack zu
|
|
// laden. Auch GEFÜLLTE zählen: der Start-Check verifiziert deren Kacheln (nach
|
|
// „Alles löschen"/Eviction werden sie automatisch neu geladen, Modell René 2026-06-08).
|
|
function _anyDeadZonesStored() {
|
|
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 || []).length > 0); db.close(); };
|
|
rq.onerror = () => { res(false); db.close(); };
|
|
};
|
|
r.onerror = () => res(false);
|
|
} catch (e) { res(false); }
|
|
});
|
|
}
|
|
|
|
// Letzte bekannte Position: GPS nur wenn Permission schon erteilt (kein Popup),
|
|
// sonst localStorage-Stand (gesetzt von wetter.js u.a.).
|
|
async function _lastKnownPos() {
|
|
try {
|
|
if (navigator.permissions && navigator.geolocation) {
|
|
const perm = await navigator.permissions.query({ name: 'geolocation' });
|
|
if (perm.state === 'granted') {
|
|
const pos = await new Promise(res =>
|
|
navigator.geolocation.getCurrentPosition(p => res(p), () => res(null), { timeout: 5000 }));
|
|
if (pos) return { lat: pos.coords.latitude, lon: pos.coords.longitude };
|
|
}
|
|
}
|
|
} catch {}
|
|
try {
|
|
const raw = localStorage.getItem(LS_LAST_POS);
|
|
if (raw) { const p = JSON.parse(raw); if (p?.lat != null) return { lat: p.lat, lon: p.lon }; }
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
// Flugmodus/Netzverlust bei OFFENER App = klares Funkloch-Signal (René 2026-06-08):
|
|
// aktuelle Position direkt als Zone merken — raw in IndexedDB, denn der GL-Stack ist
|
|
// offline evtl. nicht ladbar. Dedupe 2 km wie MapOffline.markDeadZone; Cap 50.
|
|
function _markDeadZoneRaw(lat, lon) {
|
|
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; }
|
|
const store = db.transaction('meta', 'readwrite').objectStore('meta');
|
|
const rq = store.get('deadzones');
|
|
rq.onsuccess = () => {
|
|
let zones = rq.result || [];
|
|
const near = zones.some(z => {
|
|
const dLat = (z.lat - lat) * 111, dLon = (z.lon - lon) * 111 * Math.cos(lat * Math.PI / 180);
|
|
return Math.sqrt(dLat * dLat + dLon * dLon) < 2;
|
|
});
|
|
if (!near) {
|
|
zones.push({ lat, lon, ts: Date.now(), filled: false });
|
|
if (zones.length > 50) zones = zones.slice(-50);
|
|
store.put(zones, 'deadzones');
|
|
}
|
|
db.close();
|
|
};
|
|
rq.onerror = () => db.close();
|
|
};
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Start-Check (auch bei Netz-Rückkehr): Funkloch-Zonen UND Routen-Korridore in
|
|
// Positionsnähe (50 km) füllen/verifizieren — das Gerät hält selbst aktuell, was
|
|
// offline nötig ist (René 2026-06-08: gespeicherte Routen müssen offline nutzbar
|
|
// bleiben, auch nach „Alles löschen"/Eviction). Ferne Gebiete kommen, wenn man dort ist.
|
|
let _autoFillTimer = null;
|
|
function _scheduleAutoFill(delayMs) {
|
|
if (!_offlineTilesMode()) return;
|
|
clearTimeout(_autoFillTimer);
|
|
_autoFillTimer = setTimeout(async () => {
|
|
if (!navigator.onLine) return;
|
|
try {
|
|
const hasZones = await _anyDeadZonesStored();
|
|
let routes = [];
|
|
try { routes = (await API.routes.list()) || []; } catch {}
|
|
routes = routes.filter(r => (r.preview_track || []).length >= 2);
|
|
if (!hasZones && !routes.length) return; // nichts zu tun → GL-Stack nicht laden
|
|
const pos = await _lastKnownPos();
|
|
const o = pos ? { lat: pos.lat, lon: pos.lon } : {};
|
|
await UI.loadMapLibreUI();
|
|
const n = await window.MapOffline?.autoFillDeadZones?.(o) || 0;
|
|
const k = routes.length ? (await window.MapOffline?.ensureRouteCorridors?.(routes, o) || 0) : 0;
|
|
if (n || k) {
|
|
const parts = [];
|
|
if (n) parts.push(`${n} Funkloch-${n === 1 ? 'Gebiet' : 'Gebiete'}`);
|
|
if (k) parts.push(`${k} Routen-${k === 1 ? 'Korridor' : 'Korridore'}`);
|
|
UI.toast?.info(`${parts.join(' + ')} automatisch offline gespeichert.`);
|
|
refresh();
|
|
}
|
|
} catch (e) {}
|
|
}, delayMs);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Tile-URL-Berechnung (OSM, Subdomain 'a')
|
|
// ----------------------------------------------------------
|
|
function _tile(lat, lon, z) {
|
|
const n = Math.pow(2, z);
|
|
const x = Math.floor((lon + 180) / 360 * n);
|
|
const latRad = lat * Math.PI / 180;
|
|
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1/Math.cos(latRad)) / Math.PI) / 2 * n);
|
|
return { x, y };
|
|
}
|
|
function _tileUrls(lat, lon, zoom, radius) {
|
|
const center = _tile(lat, lon, zoom);
|
|
const out = [];
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
out.push(`https://a.tile.openstreetmap.org/${zoom}/${center.x + dx}/${center.y + dy}.png`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Tile-Prefetch — versucht aktuelle GPS-Position, sonst fällt auf gespeicherte zurück
|
|
async function _prefetchTiles() {
|
|
if (!navigator.serviceWorker?.controller) return;
|
|
|
|
let lat = null, lon = null;
|
|
|
|
// 1. Versuch: GPS wenn Permission schon erteilt (kein Popup)
|
|
try {
|
|
if (navigator.permissions && navigator.geolocation) {
|
|
const perm = await navigator.permissions.query({ name: 'geolocation' });
|
|
if (perm.state === 'granted') {
|
|
const pos = await new Promise(res =>
|
|
navigator.geolocation.getCurrentPosition(p => res(p), () => res(null), { timeout: 5000 }));
|
|
if (pos) { lat = pos.coords.latitude; lon = pos.coords.longitude; }
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// 2. Fallback: zuletzt bekannte Position aus localStorage (gesetzt von wetter.js u.a.)
|
|
if (lat == null) {
|
|
try {
|
|
const raw = localStorage.getItem(LS_LAST_POS);
|
|
if (raw) {
|
|
const stored = JSON.parse(raw);
|
|
if (stored?.lat != null && stored?.lon != null) {
|
|
lat = stored.lat; lon = stored.lon;
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (lat == null) return;
|
|
const urls = [];
|
|
TILE_PREFETCH.forEach(({ zoom, radius }) =>
|
|
urls.push(..._tileUrls(lat, lon, zoom, radius)));
|
|
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// Storage-Quota überwachen — iOS-PWA hat ~50MB Limit
|
|
// ----------------------------------------------------------
|
|
let _storageWarned = false;
|
|
async function _checkStorageQuota() {
|
|
if (!navigator.storage?.estimate) return;
|
|
try {
|
|
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
|
|
if (!quota) return;
|
|
const ratio = usage / quota;
|
|
|
|
// Ab 80% Auslastung: Tile-Cache aggressiv trimmen (per SW-Message)
|
|
if (ratio >= 0.8) {
|
|
const cache = await caches.open(CACHE_TILES).catch(() => null);
|
|
if (cache) {
|
|
const keys = await cache.keys();
|
|
// Auf 100 Tiles trimmen (statt 500) bei knappem Speicher
|
|
if (keys.length > 100) {
|
|
const toDelete = keys.slice(0, keys.length - 100);
|
|
await Promise.all(toDelete.map(k => cache.delete(k).catch(() => {})));
|
|
}
|
|
}
|
|
// Einmaliger User-Hinweis pro Session bei kritischer Auslastung (>90%)
|
|
if (ratio >= 0.9 && !_storageWarned && window.UI?.toast) {
|
|
_storageWarned = true;
|
|
const mb = Math.round(usage / 1024 / 1024);
|
|
const max = Math.round(quota / 1024 / 1024);
|
|
window.UI.toast.warning(
|
|
`Speicher fast voll (${mb}/${max} MB) — älteste Karten-Tiles werden gelöscht.`,
|
|
6000
|
|
);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte
|
|
function _prefetchPages() {
|
|
['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost']
|
|
.forEach(p => fetch(`/js/pages/${p}.js?v=${window.APP_VER}`).catch(() => {}));
|
|
}
|
|
|
|
// Daten-Prefetch: Listen die offline brauchbar sein müssen,
|
|
// auch wenn der User die Seiten noch nie geöffnet hat
|
|
function _prefetchData() {
|
|
fetch('/api/expenses').catch(() => {});
|
|
fetch('/api/routes').catch(() => {});
|
|
fetch('/api/notes').catch(() => {});
|
|
const dogId = window._appState?.activeDog?.id;
|
|
if (dogId) {
|
|
fetch(`/api/dogs/${dogId}`).catch(() => {});
|
|
fetch(`/api/dogs/${dogId}/health`).catch(() => {});
|
|
fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Long-Press auf #worlds-fab → Status-Modal (normaler Click bleibt = FAB-Aktion)
|
|
function _bindLongPress() {
|
|
const fab = document.getElementById('worlds-fab');
|
|
if (!fab || fab.dataset.lpBound) return;
|
|
fab.dataset.lpBound = '1';
|
|
|
|
// iOS: Long-Press würde sonst Textauswahl/Callout-Menü triggern
|
|
fab.style.webkitUserSelect = 'none';
|
|
fab.style.userSelect = 'none';
|
|
fab.style.webkitTouchCallout = 'none';
|
|
|
|
let timer = null;
|
|
let fired = false;
|
|
const start = () => {
|
|
fired = false;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => { fired = true; openStatus(); }, 350);
|
|
};
|
|
const cancel = () => clearTimeout(timer);
|
|
fab.addEventListener('touchstart', start, { passive: true });
|
|
fab.addEventListener('touchend', cancel);
|
|
fab.addEventListener('touchmove', cancel);
|
|
fab.addEventListener('touchcancel',cancel);
|
|
fab.addEventListener('mousedown', start);
|
|
fab.addEventListener('mouseup', cancel);
|
|
fab.addEventListener('mouseleave', cancel);
|
|
// Wenn Long-Press gefeuert hat, normalen Click verhindern
|
|
fab.addEventListener('click', e => {
|
|
if (fired) { e.stopImmediatePropagation(); e.preventDefault(); fired = false; }
|
|
}, true);
|
|
}
|
|
|
|
function init() {
|
|
refresh();
|
|
_prefetchPages();
|
|
// 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();
|
|
|
|
// Mehrere Retries für hund-spezifische Daten — _appState.activeDog wird oft
|
|
// erst nach Login/Hunde-Load gesetzt, manchmal mehrere Sekunden nach Init
|
|
[2_000, 5_000, 10_000, 20_000].forEach(delay => {
|
|
setTimeout(() => { _prefetchData(); refresh(); _bindLongPress(); }, delay);
|
|
});
|
|
|
|
if (navigator.serviceWorker) {
|
|
navigator.serviceWorker.addEventListener('message', e => {
|
|
if (e?.data?.type === 'CACHE_UPDATE') refresh();
|
|
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));
|
|
// Flugmodus/Netzverlust bei offener App → Standort als Funkloch-Zone merken
|
|
// (wird beim nächsten Online-Sein automatisch geladen und künftig aktuell gehalten).
|
|
window.addEventListener('offline', async () => {
|
|
if (!_offlineTilesMode()) return;
|
|
const pos = await _lastKnownPos();
|
|
if (pos) _markDeadZoneRaw(pos.lat, pos.lon);
|
|
});
|
|
_checkStorageQuota(); // beim Init prüfen
|
|
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
|
|
}
|
|
|
|
return { init, refresh, openStatus };
|
|
})();
|
|
|
|
if (document.readyState !== 'loading') {
|
|
window.OfflineIndicator.init();
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init());
|
|
}
|