diff --git a/app/diario-bordo.html b/app/diario-bordo.html
index 06e8854..cdd6b80 100644
--- a/app/diario-bordo.html
+++ b/app/diario-bordo.html
@@ -1821,6 +1821,8 @@ async function updateStorageInfo(){
loadTrackingState();
loadAnchorState();
initBattery();
+ initServiceWorker();
+ initSensorWidget();
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
})();
@@ -2951,6 +2953,173 @@ async function testWindyKey(){
}
}
+// ===== Service Worker (offline real) =====
+function initServiceWorker(){
+ if(!('serviceWorker' in navigator))return;
+ navigator.serviceWorker.register('/sw.js').then(reg=>{
+ console.log('[SW] registered:',reg.scope);
+ }).catch(e=>console.warn('[SW] failed:',e.message));
+}
+
+// ===== Sensores: Bússola + Barômetro + Status Offline =====
+const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
+
+function initSensorWidget(){
+ // Cria widget flutuante (canto superior direito, abaixo do header)
+ const w=document.createElement('div');
+ w.id='sensors-widget';
+ w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
+ w.innerHTML='
●🧭 ---°🌡 ---▼
';
+ document.body.appendChild(w);
+ document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
+ // Online/offline status
+ updateOnlineStatus();
+ window.addEventListener('online',updateOnlineStatus);
+ window.addEventListener('offline',updateOnlineStatus);
+ // Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
+ tryStartCompass();
+ tryStartBarometer();
+}
+
+function toggleSensorPanel(){
+ const ex=document.getElementById('sw-extra');
+ const open=ex.style.display==='none';
+ ex.style.display=open?'block':'none';
+ if(open)renderSensorPanel();
+}
+
+function renderSensorPanel(){
+ const ex=document.getElementById('sw-extra');
+ const online=navigator.onLine;
+ const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
+ const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?'↓ caindo':'→ estável');
+ ex.innerHTML=`
+ Conexão: ${online?'online':'offline'}
+ Bússola: ${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}
+ Pressão: ${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}
+ Tendência: ${trend}
+
+ ${sensors.heading===null?'':''}
+
+
+
+ `;
+}
+
+function updateOnlineStatus(){
+ const dot=document.getElementById('sw-online');
+ if(!dot)return;
+ dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
+ dot.title=navigator.onLine?'Online':'Offline — usando cache';
+}
+
+function headingToCardinal(deg){
+ const dirs=['N','NE','L','SE','S','SO','O','NO'];
+ return dirs[Math.round(deg/45)%8];
+}
+
+function tryStartCompass(){
+ // iOS Safari requer permission via gesture do usuário (DeviceOrientationEvent.requestPermission)
+ // Android Chrome: funciona direto sem permission
+ if(typeof DeviceOrientationEvent==='undefined')return;
+ if(typeof DeviceOrientationEvent.requestPermission==='function'){
+ // iOS — aguarda usuário clicar "Ativar bússola"
+ return;
+ }
+ // Android: pode iniciar direto
+ attachCompassListener();
+}
+
+function requestCompassPermission(){
+ if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
+ if(typeof DeviceOrientationEvent.requestPermission==='function'){
+ DeviceOrientationEvent.requestPermission().then(state=>{
+ if(state==='granted')attachCompassListener();
+ else toast('Permissão de bússola negada');
+ }).catch(()=>toast('Falha ao pedir permissão'));
+ }else{
+ attachCompassListener();
+ }
+}
+
+function attachCompassListener(){
+ if(sensors.compassActive)return;
+ sensors.compassActive=true;
+ window.addEventListener('deviceorientationabsolute',onCompass);
+ window.addEventListener('deviceorientation',onCompass);
+}
+
+function onCompass(e){
+ // iOS: e.webkitCompassHeading (0-360 azimuth real)
+ // Android: e.alpha (0-360, mas relativo — pra absoluto, use deviceorientationabsolute)
+ let h=null;
+ if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
+ else if(typeof e.alpha==='number')h=360-e.alpha;
+ if(h===null||isNaN(h))return;
+ sensors.heading=h;
+ const c=document.getElementById('sw-compass');
+ if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
+}
+
+function tryStartBarometer(){
+ if(typeof Barometer==='undefined')return;
+ try{
+ const bar=new Barometer({frequency:1});
+ bar.addEventListener('reading',()=>{
+ sensors.pressure=bar.pressure;
+ sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
+ if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
+ // tendência: diferença entre média recente e antiga
+ if(sensors._pressHistory.length>=10){
+ const half=Math.floor(sensors._pressHistory.length/2);
+ const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
+ const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
+ sensors.pressureTrend=recent-old;
+ }
+ const el=document.getElementById('sw-pressure');
+ if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
+ });
+ bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
+ bar.start();
+ sensors.barometerActive=true;
+ }catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
+}
+
+async function precacheCurrentMapArea(){
+ if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
+ // Pede ao usuário um centro/raio se não há mapa visível
+ if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
+ if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
+ navigator.geolocation.getCurrentPosition(pos=>{
+ const lat=pos.coords.latitude,lng=pos.coords.longitude;
+ const d=0.5; // ~50km de lat/lng buffer
+ const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
+ toast('Baixando tiles…');
+ const channel=new MessageChannel();
+ channel.port1.onmessage=ev=>{
+ if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
+ if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
+ };
+ navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
+ },err=>toast('GPS erro: '+err.message),{timeout:10000});
+}
+
+async function showCacheSizes(){
+ if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
+ const channel=new MessageChannel();
+ channel.port1.onmessage=ev=>{
+ if(ev.data.type==='CACHE_SIZE_REPORT'){
+ const s=ev.data.sizes;
+ const info=document.getElementById('sw-cache-info');
+ if(info){
+ const total=Object.values(s).reduce((a,b)=>a+b,0);
+ info.innerHTML=`Cache: ${total} itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
+ }
+ }
+ };
+ navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
+}
+
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
async function initBattery(){
diff --git a/server/public/index.html b/server/public/index.html
index 06e8854..42450e7 100644
--- a/server/public/index.html
+++ b/server/public/index.html
@@ -1821,6 +1821,8 @@ async function updateStorageInfo(){
loadTrackingState();
loadAnchorState();
initBattery();
+ initServiceWorker();
+ initSensorWidget();
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
})();
@@ -2951,6 +2953,163 @@ async function testWindyKey(){
}
}
+// ===== Service Worker (offline real) =====
+function initServiceWorker(){
+ if(!('serviceWorker' in navigator))return;
+ navigator.serviceWorker.register('/sw.js').then(reg=>{
+ console.log('[SW] registered:',reg.scope);
+ }).catch(e=>console.warn('[SW] failed:',e.message));
+}
+
+// ===== Sensores: Bússola + Barômetro + Status Offline =====
+const sensors={heading:null,pressure:null,pressureTrend:null,_pressHistory:[],compassActive:false,barometerActive:false};
+
+function initSensorWidget(){
+ // Cria widget flutuante (canto superior direito, abaixo do header)
+ const w=document.createElement('div');
+ w.id='sensors-widget';
+ w.style.cssText='position:fixed;top:64px;right:12px;background:rgba(14,42,61,.92);color:#efe5cd;padding:8px 12px;border-radius:8px;font-family:var(--f-mono),monospace;font-size:11px;z-index:998;box-shadow:0 2px 8px rgba(0,0,0,.3);min-width:140px;backdrop-filter:blur(4px)';
+ w.innerHTML='●🧭 ---°🌡 ---▼
';
+ document.body.appendChild(w);
+ document.getElementById('sw-toggle').addEventListener('click',toggleSensorPanel);
+ // Online/offline status
+ updateOnlineStatus();
+ window.addEventListener('online',updateOnlineStatus);
+ window.addEventListener('offline',updateOnlineStatus);
+ // Tenta iniciar bússola e barômetro automaticamente (sem permission no Android; iOS espera tap)
+ tryStartCompass();
+ tryStartBarometer();
+}
+
+function toggleSensorPanel(){
+ const ex=document.getElementById('sw-extra');
+ const open=ex.style.display==='none';
+ ex.style.display=open?'block':'none';
+ if(open)renderSensorPanel();
+}
+
+function renderSensorPanel(){
+ const ex=document.getElementById('sw-extra');
+ const online=navigator.onLine;
+ const cardinal=sensors.heading!==null?headingToCardinal(sensors.heading):'—';
+ const trend=sensors.pressureTrend===null?'—':(sensors.pressureTrend>0?'↑ subindo':sensors.pressureTrend<0?'↓ caindo':'→ estável');
+ ex.innerHTML=`
+ Conexão: ${online?'online':'offline'}
+ Bússola: ${sensors.heading!==null?Math.round(sensors.heading)+'° '+cardinal:'sem dados'}
+ Pressão: ${sensors.pressure!==null?sensors.pressure.toFixed(1)+' hPa':'sem sensor'}
+ Tendência: ${trend}
+
+ ${sensors.heading===null?'':''}
+
+
+
+ `;
+}
+
+function updateOnlineStatus(){
+ const dot=document.getElementById('sw-online');
+ if(!dot)return;
+ dot.style.color=navigator.onLine?'#3f7768':'#8c3434';
+ dot.title=navigator.onLine?'Online':'Offline — usando cache';
+}
+
+function headingToCardinal(deg){
+ const dirs=['N','NE','L','SE','S','SO','O','NO'];
+ return dirs[Math.round(deg/45)%8];
+}
+
+function tryStartCompass(){
+ if(typeof DeviceOrientationEvent==='undefined')return;
+ if(typeof DeviceOrientationEvent.requestPermission==='function')return; // iOS espera tap
+ attachCompassListener();
+}
+
+function requestCompassPermission(){
+ if(typeof DeviceOrientationEvent==='undefined'){toast('Bússola não suportada');return}
+ if(typeof DeviceOrientationEvent.requestPermission==='function'){
+ DeviceOrientationEvent.requestPermission().then(state=>{
+ if(state==='granted')attachCompassListener();
+ else toast('Permissão de bússola negada');
+ }).catch(()=>toast('Falha ao pedir permissão'));
+ }else{
+ attachCompassListener();
+ }
+}
+
+function attachCompassListener(){
+ if(sensors.compassActive)return;
+ sensors.compassActive=true;
+ window.addEventListener('deviceorientationabsolute',onCompass);
+ window.addEventListener('deviceorientation',onCompass);
+}
+
+function onCompass(e){
+ let h=null;
+ if(typeof e.webkitCompassHeading==='number')h=e.webkitCompassHeading;
+ else if(typeof e.alpha==='number')h=360-e.alpha;
+ if(h===null||isNaN(h))return;
+ sensors.heading=h;
+ const c=document.getElementById('sw-compass');
+ if(c)c.textContent='🧭 '+Math.round(h)+'° '+headingToCardinal(h);
+}
+
+function tryStartBarometer(){
+ if(typeof Barometer==='undefined')return;
+ try{
+ const bar=new Barometer({frequency:1});
+ bar.addEventListener('reading',()=>{
+ sensors.pressure=bar.pressure;
+ sensors._pressHistory.push({ts:Date.now(),p:bar.pressure});
+ if(sensors._pressHistory.length>30)sensors._pressHistory.shift();
+ if(sensors._pressHistory.length>=10){
+ const half=Math.floor(sensors._pressHistory.length/2);
+ const old=sensors._pressHistory.slice(0,half).reduce((s,x)=>s+x.p,0)/half;
+ const recent=sensors._pressHistory.slice(half).reduce((s,x)=>s+x.p,0)/(sensors._pressHistory.length-half);
+ sensors.pressureTrend=recent-old;
+ }
+ const el=document.getElementById('sw-pressure');
+ if(el){el.textContent='🌡 '+bar.pressure.toFixed(1);el.style.opacity='1'}
+ });
+ bar.addEventListener('error',e=>console.warn('[barometer]',e.error));
+ bar.start();
+ sensors.barometerActive=true;
+ }catch(e){console.warn('[barometer] no permission/sensor:',e.message)}
+}
+
+async function precacheCurrentMapArea(){
+ if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo ainda — recarregue');return}
+ if(!confirm('Pré-cachear mapa de uma área de ~50km no entorno da posição atual?\nVai baixar ~200 tiles (uns 5-10 MB).'))return;
+ if(!navigator.geolocation){toast('Sem GPS pra centro do cache');return}
+ navigator.geolocation.getCurrentPosition(pos=>{
+ const lat=pos.coords.latitude,lng=pos.coords.longitude;
+ const d=0.5;
+ const bounds={north:lat+d,south:lat-d,east:lng+d,west:lng-d};
+ toast('Baixando tiles…');
+ const channel=new MessageChannel();
+ channel.port1.onmessage=ev=>{
+ if(ev.data.type==='PRECACHE_DONE')toast(`Mapa cacheado: ${ev.data.done}/${ev.data.total}`);
+ if(ev.data.type==='PRECACHE_ERROR')toast('Erro: '+ev.data.error);
+ };
+ navigator.serviceWorker.controller.postMessage({type:'PRECACHE_TILES',bounds,minZoom:8,maxZoom:13},[channel.port2]);
+ },err=>toast('GPS erro: '+err.message),{timeout:10000});
+}
+
+async function showCacheSizes(){
+ if(!navigator.serviceWorker||!navigator.serviceWorker.controller){toast('SW não ativo');return}
+ const channel=new MessageChannel();
+ channel.port1.onmessage=ev=>{
+ if(ev.data.type==='CACHE_SIZE_REPORT'){
+ const s=ev.data.sizes;
+ const info=document.getElementById('sw-cache-info');
+ if(info){
+ const total=Object.values(s).reduce((a,b)=>a+b,0);
+ info.innerHTML=`Cache: ${total} itens (shell ${s['shivao-shell-shivao-v1']||0}, tiles ${s['shivao-tiles-v1']||0}, windy ${s['shivao-windy-shivao-v1']||0})`;
+ }
+ }
+ };
+ navigator.serviceWorker.controller.postMessage({type:'CACHE_SIZE'},[channel.port2]);
+}
+
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
async function initBattery(){
diff --git a/server/public/sw.js b/server/public/sw.js
new file mode 100644
index 0000000..5b8d6ff
--- /dev/null
+++ b/server/public/sw.js
@@ -0,0 +1,196 @@
+// Shivao Service Worker — offline real
+// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto.
+// Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
+const VERSION = 'shivao-v1';
+const SHELL_CACHE = `shivao-shell-${VERSION}`;
+const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
+const WINDY_CACHE = `shivao-windy-${VERSION}`;
+
+// Recursos do "shell" (UI essencial). Pré-cacheados no install.
+const SHELL_URLS = [
+ '/',
+ '/manifest.json',
+ '/icon.svg',
+ // Leaflet (cdnjs)
+ 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css',
+ 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js',
+];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(SHELL_CACHE).then(async (cache) => {
+ // Tenta cachear cada um individualmente — falha de 1 não derruba install
+ await Promise.all(SHELL_URLS.map(url =>
+ cache.add(url).catch(e => console.warn('[SW] precache miss:', url, e.message))
+ ));
+ }).then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then(keys => Promise.all(
+ // Remove caches antigos do shell e windy (não tiles — que tem própria versão)
+ keys.filter(k => k.startsWith('shivao-shell-') && k !== SHELL_CACHE)
+ .concat(keys.filter(k => k.startsWith('shivao-windy-') && k !== WINDY_CACHE))
+ .map(k => caches.delete(k))
+ )).then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url);
+ if (event.request.method !== 'GET') return; // POST/PUT/DELETE passam direto
+
+ // /api do Shivão: passa direto, não interferir em sync/heartbeat/auth
+ if (url.pathname.startsWith('/api/')) return;
+
+ // Map tiles (OSM): cache-first, com network fallback
+ if (url.host === 'tile.openstreetmap.org' || url.pathname.match(/\.(png|jpg|jpeg)$/) && url.pathname.match(/\/\d+\/\d+\/\d+\./)) {
+ event.respondWith(cacheFirst(event.request, TILES_CACHE));
+ return;
+ }
+
+ // Windy API: network-first, fallback cache (mostra última previsão quando offline)
+ if (url.host === 'api.windy.com') {
+ event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000)); // TTL 1h
+ return;
+ }
+
+ // Open-Meteo (fallback meteo): mesmo padrão
+ if (url.host === 'api.open-meteo.com' || url.host === 'marine-api.open-meteo.com') {
+ event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000));
+ return;
+ }
+
+ // Fonts Google (fontes carregam no startup): cache-first
+ if (url.host === 'fonts.googleapis.com' || url.host === 'fonts.gstatic.com') {
+ event.respondWith(cacheFirst(event.request, SHELL_CACHE));
+ return;
+ }
+
+ // CDN Leaflet e similares
+ if (url.host === 'cdnjs.cloudflare.com') {
+ event.respondWith(cacheFirst(event.request, SHELL_CACHE));
+ return;
+ }
+
+ // Mesma origem (HTML/JS/CSS do app): stale-while-revalidate
+ if (url.origin === self.location.origin) {
+ event.respondWith(staleWhileRevalidate(event.request, SHELL_CACHE));
+ return;
+ }
+ // Resto: deixa rede tratar
+});
+
+async function cacheFirst(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const cached = await cache.match(request);
+ if (cached) return cached;
+ try {
+ const fresh = await fetch(request);
+ if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
+ return fresh;
+ } catch (e) {
+ return new Response('offline-no-cache', { status: 503, statusText: 'Offline' });
+ }
+}
+
+async function networkFirst(request, cacheName, ttlMs) {
+ const cache = await caches.open(cacheName);
+ try {
+ const fresh = await fetch(request);
+ if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
+ return fresh;
+ } catch (e) {
+ const cached = await cache.match(request);
+ if (cached) return cached;
+ throw e;
+ }
+}
+
+async function staleWhileRevalidate(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const cached = await cache.match(request);
+ const fetchPromise = fetch(request).then(res => {
+ if (res.ok) cache.put(request, res.clone()).catch(() => {});
+ return res;
+ }).catch(() => cached || new Response('offline', { status: 503 }));
+ return cached || fetchPromise;
+}
+
+// Mensagens da página: pré-cachear tiles de uma área
+self.addEventListener('message', (event) => {
+ if (!event.data || !event.data.type) return;
+
+ if (event.data.type === 'PRECACHE_TILES') {
+ const { bounds, minZoom, maxZoom } = event.data;
+ precacheTiles(bounds, minZoom, maxZoom).then(stats => {
+ event.source && event.source.postMessage({ type: 'PRECACHE_DONE', ...stats });
+ }).catch(err => {
+ event.source && event.source.postMessage({ type: 'PRECACHE_ERROR', error: err.message });
+ });
+ }
+
+ if (event.data.type === 'CACHE_SIZE') {
+ cacheSizes().then(sizes => {
+ event.source && event.source.postMessage({ type: 'CACHE_SIZE_REPORT', sizes });
+ });
+ }
+
+ if (event.data.type === 'CLEAR_TILES') {
+ caches.delete(TILES_CACHE).then(ok => {
+ event.source && event.source.postMessage({ type: 'TILES_CLEARED', ok });
+ });
+ }
+});
+
+// Calcula tiles necessários e baixa em paralelo (max 6 simultâneos)
+async function precacheTiles(bounds, minZoom, maxZoom) {
+ const cache = await caches.open(TILES_CACHE);
+ const tiles = [];
+ for (let z = minZoom; z <= maxZoom; z++) {
+ const min = lngLatToTile(bounds.west, bounds.north, z);
+ const max = lngLatToTile(bounds.east, bounds.south, z);
+ for (let x = min.x; x <= max.x; x++) {
+ for (let y = min.y; y <= max.y; y++) {
+ tiles.push({ z, x, y });
+ }
+ }
+ }
+
+ let done = 0, failed = 0;
+ const total = tiles.length;
+ // Limita a 6 conexões paralelas pra respeitar OSM tile policy
+ const queue = tiles.slice();
+ await Promise.all([...Array(6)].map(async () => {
+ while (queue.length) {
+ const t = queue.shift();
+ const url = `https://tile.openstreetmap.org/${t.z}/${t.x}/${t.y}.png`;
+ try {
+ const res = await fetch(url, { headers: { 'User-Agent': 'Shivao-PWA' } });
+ if (res.ok) await cache.put(url, res);
+ else failed++;
+ } catch { failed++; }
+ done++;
+ }
+ }));
+
+ return { total, done, failed };
+}
+
+function lngLatToTile(lng, lat, z) {
+ const x = Math.floor((lng + 180) / 360 * Math.pow(2, z));
+ const y = Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z));
+ return { x, y };
+}
+
+async function cacheSizes() {
+ const result = {};
+ for (const name of [SHELL_CACHE, TILES_CACHE, WINDY_CACHE]) {
+ const cache = await caches.open(name);
+ const keys = await cache.keys();
+ result[name] = keys.length;
+ }
+ return result;
+}