feat(weather): marés via GPS + alertas tempestade v1.11.0
Some checks failed
Build Android (APK + AAB) / build-android (push) Has been cancelled
Some checks failed
Build Android (APK + AAB) / build-android (push) Has been cancelled
Marés (Open-Meteo Marine API, gratuito ilimitado):
- URL marine expandida pra incluir sea_level_height_msl em hourly 48h
- extractTides() faz peak detection (máximo/mínimo local 3 pontos)
- Card '⚓ Marés' anexado ao weather widget mostrando próxima
preamar e baixamar com hora + delta tempo + altura
Alertas de tempestade:
- checkStormConditions() avalia 7 indicadores:
* vento sustentado (warn 25kn / danger 35kn)
* rajadas (warn 30kn / danger 45kn)
* ondas (warn 2m / danger 3.5m)
* pressão caindo 3hPa em 3h (tempestade aproximando)
* CAPE 1500/3000 J/kg (instabilidade atmosférica → trovoada)
- Banner sticky no topo (vermelho danger, amarelo warn) com toque
pra expandir detalhes
- Push notification via Capacitor LocalNotifications (APK) ou
Notification API (web)
- Deduplica notif: só dispara se assinatura de alertas mudou
- Permissão pedida no boot (5s delay)
Disparado automaticamente após cada fetchWeather (forecast atualiza
a cada N minutos via maybeAutoFetchWeather).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
654e597bf5
commit
f95f3a145f
6 changed files with 419 additions and 15 deletions
|
|
@ -3781,6 +3781,16 @@ async function updateStorageInfo(){
|
|||
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
|
||||
}
|
||||
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000);
|
||||
// Pede permissão de notificações (alertas tempestade)
|
||||
setTimeout(async()=>{
|
||||
try{
|
||||
const ln=window.Capacitor?.Plugins?.LocalNotifications;
|
||||
if(ln){await ln.requestPermissions().catch(()=>{})}
|
||||
else if('Notification' in window&&Notification.permission==='default'){
|
||||
await Notification.requestPermission().catch(()=>{});
|
||||
}
|
||||
}catch{}
|
||||
},5000);
|
||||
setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300);
|
||||
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
|
||||
}catch(e){
|
||||
|
|
@ -5586,19 +5596,172 @@ async function fetchWeatherOpenMeteo(lat,lng){
|
|||
if(weather.fetching)return;
|
||||
weather.fetching=true;
|
||||
try{
|
||||
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
|
||||
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`;
|
||||
// Forecast: vento, temp, pressão, lightning (CAPE), gust
|
||||
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cape&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
|
||||
// Marine: ondas + nível do mar (sea_level_height_msl = maré)
|
||||
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height,sea_level_height_msl&forecast_hours=48&timezone=auto`;
|
||||
const[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
|
||||
const f=await fR.json();
|
||||
const m=mR&&mR.ok?await mR.json():null;
|
||||
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
|
||||
weather.lastFetch=Date.now();
|
||||
weather.lastPos={lat,lng};
|
||||
weather.tides=extractTides(m);
|
||||
renderWeather();
|
||||
checkStormConditions(weather.data,{lat,lng});
|
||||
}catch(e){console.warn('weather',e.message)}
|
||||
weather.fetching=false;
|
||||
}
|
||||
|
||||
// Peak detection no array hourly de sea_level_height_msl
|
||||
// Retorna { nextHigh: {time, height_m}, nextLow: {time, height_m} }
|
||||
function extractTides(marineData){
|
||||
if(!marineData?.hourly?.sea_level_height_msl||!marineData?.hourly?.time)return null;
|
||||
const sl=marineData.hourly.sea_level_height_msl;
|
||||
const ts=marineData.hourly.time;
|
||||
const now=Date.now();
|
||||
const peaks=[]; // {idx, type:'high'|'low', height, time}
|
||||
for(let i=1;i<sl.length-1;i++){
|
||||
if(sl[i]==null||sl[i-1]==null||sl[i+1]==null)continue;
|
||||
const t=new Date(ts[i]).getTime();
|
||||
if(t<now)continue; // só futuros
|
||||
if(sl[i]>sl[i-1]&&sl[i]>sl[i+1])peaks.push({type:'high',height:sl[i],time:t});
|
||||
else if(sl[i]<sl[i-1]&&sl[i]<sl[i+1])peaks.push({type:'low',height:sl[i],time:t});
|
||||
}
|
||||
return{
|
||||
nextHigh:peaks.find(p=>p.type==='high')||null,
|
||||
nextLow:peaks.find(p=>p.type==='low')||null,
|
||||
all:peaks.slice(0,8), // próximas 8 transições (~ 4 ciclos completos)
|
||||
};
|
||||
}
|
||||
|
||||
// ============ STORM ALERTS ============
|
||||
// Limiares — futuros: configuráveis via Settings
|
||||
const STORM_THRESHOLDS={
|
||||
windWarn:25, // nós · vento sustentado
|
||||
windDanger:35,
|
||||
gustWarn:30,
|
||||
gustDanger:45,
|
||||
waveWarn:2.0, // metros
|
||||
waveDanger:3.5,
|
||||
pressureDropWarn:3, // hPa em 3h
|
||||
capeWarn:1500, // J/kg · CAPE > 1500 = atmosfera instável (trovoada)
|
||||
capeDanger:3000,
|
||||
};
|
||||
|
||||
function checkStormConditions(weatherData,pos){
|
||||
if(!weatherData)return;
|
||||
const alerts=[];
|
||||
const f=weatherData.forecast;
|
||||
const m=weatherData.marine;
|
||||
// Vento atual
|
||||
let windKn=null,gustKn=null,pressureNow=null,cape=null;
|
||||
if(weatherData.provider==='openmeteo'){
|
||||
windKn=f?.current?.wind_speed_10m;
|
||||
gustKn=f?.current?.wind_gusts_10m;
|
||||
pressureNow=f?.current?.pressure_msl;
|
||||
// CAPE atual = primeiro valor hourly
|
||||
cape=f?.hourly?.cape?.[0];
|
||||
}else if(weatherData.provider==='windy'){
|
||||
const ms=weatherData.main;
|
||||
const u=ms?.['wind_u-surface']?.[0]??0;
|
||||
const v=ms?.['wind_v-surface']?.[0]??0;
|
||||
windKn=Math.sqrt(u*u+v*v)*1.94384;
|
||||
const gu=ms?.['wind_u-gust-surface']?.[0]??ms?.['gust-surface']?.[0]??0;
|
||||
gustKn=gu*1.94384;
|
||||
pressureNow=ms?.['pressure-surface']?.[0]?ms['pressure-surface'][0]/100:null;
|
||||
}
|
||||
// Wave atual
|
||||
const waveM=m?.current?.wave_height;
|
||||
// Pressão caindo: compara atual com 3h atrás (dado anterior em hourly)
|
||||
let pressureDrop3h=0;
|
||||
if(f?.hourly?.pressure_msl?.length>3){
|
||||
const p0=f.hourly.pressure_msl[0];
|
||||
const p3=f.hourly.pressure_msl[3];
|
||||
if(p0!=null&&p3!=null)pressureDrop3h=p0-p3;
|
||||
}
|
||||
// Avalia condições
|
||||
if(windKn!=null){
|
||||
if(windKn>=STORM_THRESHOLDS.windDanger)alerts.push({level:'danger',title:'Vento crítico',msg:`${windKn.toFixed(0)}kn · evite navegar`});
|
||||
else if(windKn>=STORM_THRESHOLDS.windWarn)alerts.push({level:'warn',title:'Vento forte',msg:`${windKn.toFixed(0)}kn · cuidado`});
|
||||
}
|
||||
if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustDanger)alerts.push({level:'danger',title:'Rajadas perigosas',msg:`pico ${gustKn.toFixed(0)}kn`});
|
||||
else if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustWarn)alerts.push({level:'warn',title:'Rajadas fortes',msg:`pico ${gustKn.toFixed(0)}kn`});
|
||||
if(waveM!=null){
|
||||
if(waveM>=STORM_THRESHOLDS.waveDanger)alerts.push({level:'danger',title:'Mar perigoso',msg:`ondas ${waveM.toFixed(1)}m`});
|
||||
else if(waveM>=STORM_THRESHOLDS.waveWarn)alerts.push({level:'warn',title:'Mar agitado',msg:`ondas ${waveM.toFixed(1)}m`});
|
||||
}
|
||||
if(pressureDrop3h>=STORM_THRESHOLDS.pressureDropWarn)alerts.push({level:'warn',title:'Pressão caindo',msg:`-${pressureDrop3h.toFixed(1)}hPa em 3h · tempestade aproximando`});
|
||||
if(cape!=null){
|
||||
if(cape>=STORM_THRESHOLDS.capeDanger)alerts.push({level:'danger',title:'Trovoada provável',msg:`CAPE ${Math.round(cape)} J/kg · risco de raios`});
|
||||
else if(cape>=STORM_THRESHOLDS.capeWarn)alerts.push({level:'warn',title:'Atmosfera instável',msg:`CAPE ${Math.round(cape)} · trovoadas isoladas`});
|
||||
}
|
||||
// Salva no state pra UI ler
|
||||
weather.alerts=alerts;
|
||||
// Renderiza banner se alguma
|
||||
renderStormBanner();
|
||||
// Notificação push em alertas novos (compara com último set)
|
||||
const sig=alerts.map(a=>a.level+':'+a.title).join('|');
|
||||
if(alerts.length>0&&sig!==weather._lastAlertSig){
|
||||
weather._lastAlertSig=sig;
|
||||
sendStormNotification(alerts);
|
||||
}else if(alerts.length===0){
|
||||
weather._lastAlertSig='';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStormBanner(){
|
||||
let banner=document.getElementById('storm-banner');
|
||||
const alerts=weather.alerts||[];
|
||||
if(alerts.length===0){
|
||||
if(banner)banner.remove();
|
||||
return;
|
||||
}
|
||||
if(!banner){
|
||||
banner=document.createElement('div');
|
||||
banner.id='storm-banner';
|
||||
banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9000;padding:10px 14px;font-family:var(--f-body);font-size:13px;font-weight:600;text-align:center;cursor:pointer;backdrop-filter:blur(8px)';
|
||||
banner.onclick=()=>{
|
||||
const det=document.getElementById('storm-banner-details');
|
||||
if(det)det.style.display=det.style.display==='block'?'none':'block';
|
||||
};
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
|
||||
const bg=worst.level==='danger'?'rgba(239,68,68,.94)':'rgba(245,158,11,.94)';
|
||||
banner.style.background=bg;
|
||||
banner.style.color='#fff';
|
||||
banner.innerHTML=`⚠ ${worst.title} · ${worst.msg}${alerts.length>1?` (+${alerts.length-1})`:''} · toque pra detalhes
|
||||
<div id="storm-banner-details" style="display:none;margin-top:8px;font-size:11.5px;font-weight:400;text-align:left;line-height:1.6">
|
||||
${alerts.map(a=>`<div>${a.level==='danger'?'🔴':'🟡'} <strong>${a.title}</strong>: ${a.msg}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function sendStormNotification(alerts){
|
||||
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
|
||||
const title=worst.level==='danger'?'⚠ ALERTA CRÍTICO':'🟡 Aviso meteorológico';
|
||||
const body=alerts.map(a=>`${a.title}: ${a.msg}`).join(' · ');
|
||||
// Capacitor LocalNotifications
|
||||
try{
|
||||
const ln=window.Capacitor?.Plugins?.LocalNotifications;
|
||||
if(ln){
|
||||
await ln.schedule({notifications:[{
|
||||
id:Math.floor(Math.random()*1e9),
|
||||
title,body,smallIcon:'ic_stat_anchor',
|
||||
}]});
|
||||
return;
|
||||
}
|
||||
}catch(e){console.warn('storm notif',e.message)}
|
||||
// Web Notifications API
|
||||
if('Notification' in window){
|
||||
if(Notification.permission==='granted'){
|
||||
try{new Notification(title,{body,icon:'/icon.svg',tag:'storm-alert'})}catch{}
|
||||
}else if(Notification.permission!=='denied'){
|
||||
try{await Notification.requestPermission()}catch{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers de conversão de unidades
|
||||
function uvToSpeedDir(u,v){
|
||||
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
|
||||
|
|
@ -5624,8 +5787,47 @@ function renderWeather(){
|
|||
if(!el)return;
|
||||
const d=weather.data;
|
||||
if(!d){el.innerHTML='';return}
|
||||
if(d.provider==='windy')return renderWindyWeather(el,d);
|
||||
return renderOpenMeteoWeather(el,d);
|
||||
if(d.provider==='windy')renderWindyWeather(el,d);
|
||||
else renderOpenMeteoWeather(el,d);
|
||||
// Anexa card de marés se tiver dados
|
||||
appendTidesCard(el);
|
||||
}
|
||||
|
||||
function appendTidesCard(weatherEl){
|
||||
if(!weather.tides||!weather.tides.nextHigh)return;
|
||||
const t=weather.tides;
|
||||
const fmtTide=(p)=>{
|
||||
if(!p)return '—';
|
||||
const d=new Date(p.time);
|
||||
const hh=d.getHours().toString().padStart(2,'0');
|
||||
const mm=d.getMinutes().toString().padStart(2,'0');
|
||||
const diffMin=Math.round((d.getTime()-Date.now())/60000);
|
||||
const inH=diffMin>=60?`em ${Math.floor(diffMin/60)}h${(diffMin%60).toString().padStart(2,'0')}`:`em ${diffMin}min`;
|
||||
return `${hh}:${mm} (${inH}) · ${p.height>=0?'+':''}${p.height.toFixed(2)}m`;
|
||||
};
|
||||
const tidesHtml=`
|
||||
<div style="margin-top:10px;padding:10px;background:var(--m-bg-2,rgba(0,0,0,.15));border-radius:8px;border-left:3px solid var(--m-accent,#06b6d4)">
|
||||
<div style="font-family:var(--f-mono);font-size:10px;letter-spacing:.14em;color:var(--m-text-soft,#7d97ad);text-transform:uppercase;margin-bottom:6px">⚓ Marés (próx. 48h)</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↑ PREAMAR</div>
|
||||
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextHigh)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↓ BAIXAMAR</div>
|
||||
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextLow)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Append apenas se ainda não existe
|
||||
if(!weatherEl.querySelector('.tides-block')){
|
||||
const div=document.createElement('div');
|
||||
div.className='tides-block';
|
||||
div.innerHTML=tidesHtml;
|
||||
weatherEl.appendChild(div);
|
||||
}else{
|
||||
weatherEl.querySelector('.tides-block').innerHTML=tidesHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWindyWeather(el,d){
|
||||
|
|
@ -6506,7 +6708,7 @@ async function removeBluetoothDevice(id){
|
|||
renderBluetoothCard();
|
||||
}
|
||||
|
||||
const APP_VERSION='1.10.19';
|
||||
const APP_VERSION='1.11.0';
|
||||
function renderBluetoothCard(){
|
||||
const el=document.getElementById('bt-list');
|
||||
const supportEl=document.getElementById('bt-support');
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ android {
|
|||
applicationId "br.com.pontualtech.shivao"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 31
|
||||
versionName "1.10.18"
|
||||
versionCode 32
|
||||
versionName "1.11.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "shivao-mobile",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -3781,6 +3781,16 @@ async function updateStorageInfo(){
|
|||
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
|
||||
}
|
||||
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000);
|
||||
// Pede permissão de notificações (alertas tempestade)
|
||||
setTimeout(async()=>{
|
||||
try{
|
||||
const ln=window.Capacitor?.Plugins?.LocalNotifications;
|
||||
if(ln){await ln.requestPermissions().catch(()=>{})}
|
||||
else if('Notification' in window&&Notification.permission==='default'){
|
||||
await Notification.requestPermission().catch(()=>{});
|
||||
}
|
||||
}catch{}
|
||||
},5000);
|
||||
setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300);
|
||||
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
|
||||
}catch(e){
|
||||
|
|
@ -5586,19 +5596,172 @@ async function fetchWeatherOpenMeteo(lat,lng){
|
|||
if(weather.fetching)return;
|
||||
weather.fetching=true;
|
||||
try{
|
||||
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
|
||||
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`;
|
||||
// Forecast: vento, temp, pressão, lightning (CAPE), gust
|
||||
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cape&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
|
||||
// Marine: ondas + nível do mar (sea_level_height_msl = maré)
|
||||
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height,sea_level_height_msl&forecast_hours=48&timezone=auto`;
|
||||
const[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
|
||||
const f=await fR.json();
|
||||
const m=mR&&mR.ok?await mR.json():null;
|
||||
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
|
||||
weather.lastFetch=Date.now();
|
||||
weather.lastPos={lat,lng};
|
||||
weather.tides=extractTides(m);
|
||||
renderWeather();
|
||||
checkStormConditions(weather.data,{lat,lng});
|
||||
}catch(e){console.warn('weather',e.message)}
|
||||
weather.fetching=false;
|
||||
}
|
||||
|
||||
// Peak detection no array hourly de sea_level_height_msl
|
||||
// Retorna { nextHigh: {time, height_m}, nextLow: {time, height_m} }
|
||||
function extractTides(marineData){
|
||||
if(!marineData?.hourly?.sea_level_height_msl||!marineData?.hourly?.time)return null;
|
||||
const sl=marineData.hourly.sea_level_height_msl;
|
||||
const ts=marineData.hourly.time;
|
||||
const now=Date.now();
|
||||
const peaks=[]; // {idx, type:'high'|'low', height, time}
|
||||
for(let i=1;i<sl.length-1;i++){
|
||||
if(sl[i]==null||sl[i-1]==null||sl[i+1]==null)continue;
|
||||
const t=new Date(ts[i]).getTime();
|
||||
if(t<now)continue; // só futuros
|
||||
if(sl[i]>sl[i-1]&&sl[i]>sl[i+1])peaks.push({type:'high',height:sl[i],time:t});
|
||||
else if(sl[i]<sl[i-1]&&sl[i]<sl[i+1])peaks.push({type:'low',height:sl[i],time:t});
|
||||
}
|
||||
return{
|
||||
nextHigh:peaks.find(p=>p.type==='high')||null,
|
||||
nextLow:peaks.find(p=>p.type==='low')||null,
|
||||
all:peaks.slice(0,8), // próximas 8 transições (~ 4 ciclos completos)
|
||||
};
|
||||
}
|
||||
|
||||
// ============ STORM ALERTS ============
|
||||
// Limiares — futuros: configuráveis via Settings
|
||||
const STORM_THRESHOLDS={
|
||||
windWarn:25, // nós · vento sustentado
|
||||
windDanger:35,
|
||||
gustWarn:30,
|
||||
gustDanger:45,
|
||||
waveWarn:2.0, // metros
|
||||
waveDanger:3.5,
|
||||
pressureDropWarn:3, // hPa em 3h
|
||||
capeWarn:1500, // J/kg · CAPE > 1500 = atmosfera instável (trovoada)
|
||||
capeDanger:3000,
|
||||
};
|
||||
|
||||
function checkStormConditions(weatherData,pos){
|
||||
if(!weatherData)return;
|
||||
const alerts=[];
|
||||
const f=weatherData.forecast;
|
||||
const m=weatherData.marine;
|
||||
// Vento atual
|
||||
let windKn=null,gustKn=null,pressureNow=null,cape=null;
|
||||
if(weatherData.provider==='openmeteo'){
|
||||
windKn=f?.current?.wind_speed_10m;
|
||||
gustKn=f?.current?.wind_gusts_10m;
|
||||
pressureNow=f?.current?.pressure_msl;
|
||||
// CAPE atual = primeiro valor hourly
|
||||
cape=f?.hourly?.cape?.[0];
|
||||
}else if(weatherData.provider==='windy'){
|
||||
const ms=weatherData.main;
|
||||
const u=ms?.['wind_u-surface']?.[0]??0;
|
||||
const v=ms?.['wind_v-surface']?.[0]??0;
|
||||
windKn=Math.sqrt(u*u+v*v)*1.94384;
|
||||
const gu=ms?.['wind_u-gust-surface']?.[0]??ms?.['gust-surface']?.[0]??0;
|
||||
gustKn=gu*1.94384;
|
||||
pressureNow=ms?.['pressure-surface']?.[0]?ms['pressure-surface'][0]/100:null;
|
||||
}
|
||||
// Wave atual
|
||||
const waveM=m?.current?.wave_height;
|
||||
// Pressão caindo: compara atual com 3h atrás (dado anterior em hourly)
|
||||
let pressureDrop3h=0;
|
||||
if(f?.hourly?.pressure_msl?.length>3){
|
||||
const p0=f.hourly.pressure_msl[0];
|
||||
const p3=f.hourly.pressure_msl[3];
|
||||
if(p0!=null&&p3!=null)pressureDrop3h=p0-p3;
|
||||
}
|
||||
// Avalia condições
|
||||
if(windKn!=null){
|
||||
if(windKn>=STORM_THRESHOLDS.windDanger)alerts.push({level:'danger',title:'Vento crítico',msg:`${windKn.toFixed(0)}kn · evite navegar`});
|
||||
else if(windKn>=STORM_THRESHOLDS.windWarn)alerts.push({level:'warn',title:'Vento forte',msg:`${windKn.toFixed(0)}kn · cuidado`});
|
||||
}
|
||||
if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustDanger)alerts.push({level:'danger',title:'Rajadas perigosas',msg:`pico ${gustKn.toFixed(0)}kn`});
|
||||
else if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustWarn)alerts.push({level:'warn',title:'Rajadas fortes',msg:`pico ${gustKn.toFixed(0)}kn`});
|
||||
if(waveM!=null){
|
||||
if(waveM>=STORM_THRESHOLDS.waveDanger)alerts.push({level:'danger',title:'Mar perigoso',msg:`ondas ${waveM.toFixed(1)}m`});
|
||||
else if(waveM>=STORM_THRESHOLDS.waveWarn)alerts.push({level:'warn',title:'Mar agitado',msg:`ondas ${waveM.toFixed(1)}m`});
|
||||
}
|
||||
if(pressureDrop3h>=STORM_THRESHOLDS.pressureDropWarn)alerts.push({level:'warn',title:'Pressão caindo',msg:`-${pressureDrop3h.toFixed(1)}hPa em 3h · tempestade aproximando`});
|
||||
if(cape!=null){
|
||||
if(cape>=STORM_THRESHOLDS.capeDanger)alerts.push({level:'danger',title:'Trovoada provável',msg:`CAPE ${Math.round(cape)} J/kg · risco de raios`});
|
||||
else if(cape>=STORM_THRESHOLDS.capeWarn)alerts.push({level:'warn',title:'Atmosfera instável',msg:`CAPE ${Math.round(cape)} · trovoadas isoladas`});
|
||||
}
|
||||
// Salva no state pra UI ler
|
||||
weather.alerts=alerts;
|
||||
// Renderiza banner se alguma
|
||||
renderStormBanner();
|
||||
// Notificação push em alertas novos (compara com último set)
|
||||
const sig=alerts.map(a=>a.level+':'+a.title).join('|');
|
||||
if(alerts.length>0&&sig!==weather._lastAlertSig){
|
||||
weather._lastAlertSig=sig;
|
||||
sendStormNotification(alerts);
|
||||
}else if(alerts.length===0){
|
||||
weather._lastAlertSig='';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStormBanner(){
|
||||
let banner=document.getElementById('storm-banner');
|
||||
const alerts=weather.alerts||[];
|
||||
if(alerts.length===0){
|
||||
if(banner)banner.remove();
|
||||
return;
|
||||
}
|
||||
if(!banner){
|
||||
banner=document.createElement('div');
|
||||
banner.id='storm-banner';
|
||||
banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9000;padding:10px 14px;font-family:var(--f-body);font-size:13px;font-weight:600;text-align:center;cursor:pointer;backdrop-filter:blur(8px)';
|
||||
banner.onclick=()=>{
|
||||
const det=document.getElementById('storm-banner-details');
|
||||
if(det)det.style.display=det.style.display==='block'?'none':'block';
|
||||
};
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
|
||||
const bg=worst.level==='danger'?'rgba(239,68,68,.94)':'rgba(245,158,11,.94)';
|
||||
banner.style.background=bg;
|
||||
banner.style.color='#fff';
|
||||
banner.innerHTML=`⚠ ${worst.title} · ${worst.msg}${alerts.length>1?` (+${alerts.length-1})`:''} · toque pra detalhes
|
||||
<div id="storm-banner-details" style="display:none;margin-top:8px;font-size:11.5px;font-weight:400;text-align:left;line-height:1.6">
|
||||
${alerts.map(a=>`<div>${a.level==='danger'?'🔴':'🟡'} <strong>${a.title}</strong>: ${a.msg}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function sendStormNotification(alerts){
|
||||
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
|
||||
const title=worst.level==='danger'?'⚠ ALERTA CRÍTICO':'🟡 Aviso meteorológico';
|
||||
const body=alerts.map(a=>`${a.title}: ${a.msg}`).join(' · ');
|
||||
// Capacitor LocalNotifications
|
||||
try{
|
||||
const ln=window.Capacitor?.Plugins?.LocalNotifications;
|
||||
if(ln){
|
||||
await ln.schedule({notifications:[{
|
||||
id:Math.floor(Math.random()*1e9),
|
||||
title,body,smallIcon:'ic_stat_anchor',
|
||||
}]});
|
||||
return;
|
||||
}
|
||||
}catch(e){console.warn('storm notif',e.message)}
|
||||
// Web Notifications API
|
||||
if('Notification' in window){
|
||||
if(Notification.permission==='granted'){
|
||||
try{new Notification(title,{body,icon:'/icon.svg',tag:'storm-alert'})}catch{}
|
||||
}else if(Notification.permission!=='denied'){
|
||||
try{await Notification.requestPermission()}catch{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers de conversão de unidades
|
||||
function uvToSpeedDir(u,v){
|
||||
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
|
||||
|
|
@ -5624,8 +5787,47 @@ function renderWeather(){
|
|||
if(!el)return;
|
||||
const d=weather.data;
|
||||
if(!d){el.innerHTML='';return}
|
||||
if(d.provider==='windy')return renderWindyWeather(el,d);
|
||||
return renderOpenMeteoWeather(el,d);
|
||||
if(d.provider==='windy')renderWindyWeather(el,d);
|
||||
else renderOpenMeteoWeather(el,d);
|
||||
// Anexa card de marés se tiver dados
|
||||
appendTidesCard(el);
|
||||
}
|
||||
|
||||
function appendTidesCard(weatherEl){
|
||||
if(!weather.tides||!weather.tides.nextHigh)return;
|
||||
const t=weather.tides;
|
||||
const fmtTide=(p)=>{
|
||||
if(!p)return '—';
|
||||
const d=new Date(p.time);
|
||||
const hh=d.getHours().toString().padStart(2,'0');
|
||||
const mm=d.getMinutes().toString().padStart(2,'0');
|
||||
const diffMin=Math.round((d.getTime()-Date.now())/60000);
|
||||
const inH=diffMin>=60?`em ${Math.floor(diffMin/60)}h${(diffMin%60).toString().padStart(2,'0')}`:`em ${diffMin}min`;
|
||||
return `${hh}:${mm} (${inH}) · ${p.height>=0?'+':''}${p.height.toFixed(2)}m`;
|
||||
};
|
||||
const tidesHtml=`
|
||||
<div style="margin-top:10px;padding:10px;background:var(--m-bg-2,rgba(0,0,0,.15));border-radius:8px;border-left:3px solid var(--m-accent,#06b6d4)">
|
||||
<div style="font-family:var(--f-mono);font-size:10px;letter-spacing:.14em;color:var(--m-text-soft,#7d97ad);text-transform:uppercase;margin-bottom:6px">⚓ Marés (próx. 48h)</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↑ PREAMAR</div>
|
||||
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextHigh)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↓ BAIXAMAR</div>
|
||||
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextLow)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Append apenas se ainda não existe
|
||||
if(!weatherEl.querySelector('.tides-block')){
|
||||
const div=document.createElement('div');
|
||||
div.className='tides-block';
|
||||
div.innerHTML=tidesHtml;
|
||||
weatherEl.appendChild(div);
|
||||
}else{
|
||||
weatherEl.querySelector('.tides-block').innerHTML=tidesHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWindyWeather(el,d){
|
||||
|
|
@ -6506,7 +6708,7 @@ async function removeBluetoothDevice(id){
|
|||
renderBluetoothCard();
|
||||
}
|
||||
|
||||
const APP_VERSION='1.10.19';
|
||||
const APP_VERSION='1.11.0';
|
||||
function renderBluetoothCard(){
|
||||
const el=document.getElementById('bt-list');
|
||||
const supportEl=document.getElementById('bt-support');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// 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.10.19';
|
||||
const VERSION = 'shivao-v1.11.0';
|
||||
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}`;
|
||||
|
|
|
|||
|
|
@ -413,7 +413,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
|
|||
});
|
||||
|
||||
// Atalho: /apk redireciona pra última APK release no Forgejo
|
||||
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.10.18/Shivao-v1.10.18.apk';
|
||||
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.11.0/Shivao-v1.11.0.apk';
|
||||
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
|
||||
|
||||
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)
|
||||
|
|
|
|||
Loading…
Reference in a new issue