From d1a2401048d4e64cb4ee76102bea3afcb1924c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PontualTech=20/=20Karl=C3=A3o?= Date: Mon, 27 Apr 2026 14:55:39 -0300 Subject: [PATCH] =?UTF-8?q?feat(offline+sensors):=20Service=20Worker,=20b?= =?UTF-8?q?=C3=BAssola,=20bar=C3=B4metro,=20pr=C3=A9-cache=20de=20mapa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa requisitos pra uso em áreas remotas: OFFLINE REAL (Service Worker em server/public/sw.js) - Pré-cache de shell (HTML, manifest, icon, Leaflet, fontes Google) - Cache-first pra map tiles OSM (offline em alto-mar com tiles já visitados) - Network-first pra Windy/Open-Meteo (com fallback ao cache) - /api/* passa direto (não interferir em sync, heartbeat, auth) - Skip-waiting + claim pra ativar imediatamente após install SENSORES (sensor widget flutuante canto superior direito) - Bússola via DeviceOrientationEvent (suporta iOS webkitCompassHeading + Android alpha) - iOS: pede permission via gesture do usuário (botão 'Ativar bússola') - Barômetro via Generic Sensor API (Android com sensor real, fallback gracioso) - Tendência de pressão (subindo/caindo/estável) baseada em janela móvel - Indicador de online/offline sempre visível PRÉ-CACHE DE MAPA - Botão 'Pré-cachear mapa' baixa tiles ~50km de raio (zooms 8-13, ~200 tiles) - Comunicação page→SW via MessageChannel - Limit 6 conexões paralelas (respeitando OSM tile policy) DOCUMENTAÇÃO TERMÔMETRO: API web não tem termômetro de ambiente. Solução: usar dado da Windy (já implementado) + cache offline via SW. Sincronizado em app/ e server/public/ — single-file HTML preservado. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/diario-bordo.html | 169 +++++++++++++++++++++++++++++++++ server/public/index.html | 159 +++++++++++++++++++++++++++++++ server/public/sw.js | 196 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 server/public/sw.js 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; +}