feat(fleet): sistema multi-embarcação + calculadora de fundeio v1.4.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

— Frota: gerencia múltiplas embarcações (veleiro/motor/cata/RIB/outro)
— Selector de barco ativo no header, modal de gerência completo
— Campos por barco: nome, tipo, modelo, comprimento, boca, calado, amarra, ano
— Toggle global de unidades (metros/pés) com conversão em todos displays
— Calculadora de fundeio: scope ratio, raio de giro, raio sugerido p/ alarme
— Dicas adaptativas por vento (auto-fetch Windy/OpenMeteo) + tipo de embarcação
— Migration automática state.boat → state.boats[] (compat retroativa)
— Storage interno sempre em metros (ISO), conversão só no boundary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 06:21:29 -03:00
parent fe78a4afa9
commit 7ccaa18bfa
3 changed files with 1240 additions and 20 deletions

View file

@ -89,11 +89,29 @@ header{
padding:1px 0;width:100%;
}
.boat-name:focus{outline:none;border-bottom:1px dashed var(--brass)}
.boat-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.06em;color:rgba(250,242,221,.65);margin-top:2px}
.boat-selector{
display:inline-flex;align-items:center;gap:6px;cursor:pointer;
text-align:left;width:auto;
transition:opacity .15s;
}
.boat-selector:hover{opacity:.8}
.boat-selector:active{transform:scale(.98)}
.boat-chevron{font-size:14px;opacity:.6;font-style:normal;transform:translateY(1px)}
.boat-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.06em;color:rgba(250,242,221,.65);margin-top:2px;display:flex;align-items:center;gap:8px}
.boat-meta input{
background:transparent;border:none;color:inherit;
font:inherit;padding:1px 0;width:100%;
}
.boat-model-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.boat-edit-btn{
background:rgba(250,242,221,.08);border:1px solid rgba(250,242,221,.18);
color:rgba(250,242,221,.7);cursor:pointer;
width:22px;height:22px;border-radius:6px;
display:inline-flex;align-items:center;justify-content:center;
font-size:11px;flex-shrink:0;
transition:all .15s;
}
.boat-edit-btn:hover{background:rgba(250,242,221,.15);color:var(--bg-paper)}
.boat-meta input::placeholder{color:rgba(250,242,221,.4)}
.boat-meta input:focus{outline:none;border-bottom:1px dashed var(--brass)}
.boat-coord{font-family:var(--f-mono);font-size:9.5px;color:var(--brass-bright);letter-spacing:.1em;text-align:right;flex-shrink:0;line-height:1.5}
@ -465,6 +483,93 @@ header{
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.field-hint{font-size:10px;color:var(--sepia);margin-top:4px;font-style:italic;font-family:var(--f-display)}
/* === Fleet Manager === */
.fleet-list{display:flex;flex-direction:column;gap:8px;margin-bottom:18px;max-height:50vh;overflow-y:auto}
.fleet-item{
display:flex;align-items:center;gap:12px;
padding:12px 14px;background:var(--bg-canvas);
border:1px solid var(--rule);cursor:pointer;
transition:all .15s;
}
.fleet-item:hover{background:var(--bg-aged);border-color:var(--sepia)}
.fleet-item.active{border-left:3px solid var(--brass);background:var(--bg-aged)}
.fleet-icon{font-size:24px;flex-shrink:0;width:32px;text-align:center}
.fleet-info{flex:1;min-width:0}
.fleet-name{font-family:var(--f-display);font-style:italic;font-size:17px;color:var(--ink-deep);font-weight:500}
.fleet-meta{font-family:var(--f-mono);font-size:10px;letter-spacing:.06em;color:var(--sepia);margin-top:2px;text-transform:uppercase}
.fleet-active-badge{
background:var(--brass);color:var(--bg-paper);
font-family:var(--f-mono);font-size:9px;letter-spacing:.12em;
padding:3px 8px;text-transform:uppercase;
}
.fleet-edit-icon{
background:transparent;border:1px solid var(--rule);
width:28px;height:28px;display:flex;align-items:center;justify-content:center;
cursor:pointer;color:var(--sepia);font-size:12px;flex-shrink:0;
}
.fleet-edit-icon:hover{border-color:var(--brass);color:var(--brass)}
.fleet-empty{
text-align:center;padding:24px;color:var(--sepia);
font-family:var(--f-display);font-style:italic;font-size:14px;
border:1px dashed var(--rule);
}
.fleet-units-row{
display:flex;align-items:center;justify-content:space-between;
padding:14px 0 0;border-top:1px solid var(--rule);
}
.fleet-units-label{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--sepia)}
.fleet-units-toggle{display:flex;border:1px solid var(--rule)}
.fleet-units-toggle button{
background:var(--bg-canvas);border:none;
padding:7px 14px;font-family:var(--f-mono);font-size:11px;
letter-spacing:.08em;cursor:pointer;color:var(--ink-mid);
transition:all .15s;
}
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Anchor Calculator === */
.anchor-calc{
background:var(--bg-canvas);border:1px solid var(--rule);
border-left:3px solid var(--ocean);
padding:14px;margin:14px 0;
}
.anchor-calc-head{
font-family:var(--f-mono);font-size:11px;letter-spacing:.14em;
text-transform:uppercase;color:var(--ocean);
margin-bottom:10px;font-weight:600;
}
.anchor-calc .field{margin-bottom:10px}
.anchor-calc-result{
display:grid;grid-template-columns:1fr 1fr;gap:8px;
margin-top:10px;
}
.anchor-calc-result.full{grid-template-columns:1fr}
.anchor-calc-stat{
background:var(--bg-paper);padding:10px;border:1px solid var(--rule-soft);
}
.anchor-calc-stat-label{
font-family:var(--f-mono);font-size:9.5px;letter-spacing:.14em;
text-transform:uppercase;color:var(--sepia);margin-bottom:4px;
}
.anchor-calc-stat-value{
font-family:var(--f-display);font-style:italic;font-size:20px;
color:var(--ink-deep);font-weight:500;line-height:1;
}
.anchor-calc-stat-value.ok{color:var(--algae,#4a8c4a)}
.anchor-calc-stat-value.warn{color:var(--sun,#c8943a)}
.anchor-calc-stat-value.danger{color:var(--storm,#b04040)}
.anchor-calc-advice{
grid-column:1/-1;
padding:10px 12px;background:var(--ocean-soft,rgba(50,90,140,.08));
border-left:2px solid var(--ocean);
font-family:var(--f-display);font-style:italic;font-size:13px;
color:var(--ink-deep);line-height:1.5;
}
.anchor-calc-advice.danger{background:var(--storm-soft,rgba(176,64,64,.08));border-color:var(--storm)}
.anchor-calc-advice.warn{background:var(--sun-soft,rgba(200,148,58,.1));border-color:var(--sun)}
.anchor-calc-advice.ok{background:rgba(74,140,74,.08);border-color:var(--algae,#4a8c4a)}
.pax-input-row{display:flex;gap:6px}
.pax-input-row input{flex:1}
.pax-list{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;min-height:24px}
@ -1235,8 +1340,14 @@ header{
</svg>
<div class="boat-info">
<div class="boat-tagline">Diário de Bordo · Logbook</div>
<input class="boat-name" id="boat-name" value="Shivao" maxlength="40" spellcheck="false">
<div class="boat-meta"><input id="boat-model" placeholder="Modelo / Classe / Marina" maxlength="48"></div>
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
<span id="boat-name-display">Shivao</span>
<span class="boat-chevron" aria-hidden="true"></span>
</button>
<div class="boat-meta">
<span id="boat-model-display" class="boat-model-text">⛵ Veleiro</span>
<button type="button" class="boat-edit-btn" onclick="openFleetManager()" title="Editar embarcação"></button>
</div>
</div>
</div>
</header>
@ -1513,19 +1624,38 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<!-- Anchor Setup Modal -->
<div class="modal-backdrop" id="anchor-setup-modal">
<div class="modal">
<div class="modal-head"><h3>Fundear o Shivao</h3><button class="icon-btn" onclick="closeModal('anchor-setup-modal')"></button></div>
<div class="modal-head"><h3 id="anchor-modal-title">Fundear</h3><button class="icon-btn" onclick="closeModal('anchor-setup-modal')"></button></div>
<div class="modal-body">
<div id="anchor-setup-status" style="font-family:var(--f-display);font-style:italic;font-size:14px;color:var(--sepia);margin-bottom:14px;text-align:center;padding:16px;background:var(--bg-canvas);border:1px dashed var(--rule)">Aguardando posição do GPS…</div>
<div class="field"><label class="field-label">Posição registrada</label>
<div id="anchor-coord" style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);padding:10px 12px;background:var(--bg-canvas);border:1px solid var(--rule)"></div>
</div>
<!-- Anchor Calculator -->
<div class="anchor-calc">
<div class="anchor-calc-head">⚓ Calculadora de fundeio</div>
<div class="field-row">
<div class="field"><label class="field-label">Profundidade <span id="anchor-depth-unit">(m)</span></label>
<input type="number" id="anchor-depth" step="0.5" min="0" placeholder="0.0" oninput="recalcAnchor()">
</div>
<div class="field"><label class="field-label">Amarra lançada <span id="anchor-chain-unit">(m)</span></label>
<input type="number" id="anchor-chain" step="1" min="0" placeholder="0" oninput="recalcAnchor()">
</div>
</div>
<div class="field"><label class="field-label">Vento atual (nós) <span id="anchor-wind-source" style="font-weight:400;color:var(--sepia);text-transform:none;letter-spacing:0">manual</span></label>
<input type="number" id="anchor-wind" step="1" min="0" placeholder="ex: 12" oninput="recalcAnchor()">
</div>
<div id="anchor-calc-result" class="anchor-calc-result"></div>
<button type="button" class="btn btn-block btn-sm" onclick="applyRecommendedRadius()" id="anchor-apply-recommend" style="display:none;margin-top:8px">⚓ Aplicar raio sugerido</button>
</div>
<div class="field">
<label class="field-label">Raio de segurança</label>
<label class="field-label">Raio de segurança (alarme)</label>
<div class="anchor-radius-row">
<input type="range" id="anchor-radius" min="15" max="200" step="5" value="50" oninput="updateRadiusLabel(this.value)">
<span class="anchor-radius-val" id="anchor-radius-val">50 m</span>
</div>
<div class="field-hint">Distância máxima da âncora antes de disparar o alarme. Considere: linha de fundeio + maré + giro do barco.</div>
<div class="field-hint">Distância máxima da âncora antes de disparar o alarme. Use a calculadora acima pra estimar.</div>
</div>
<div class="field"><label class="field-label">Contatos de emergência</label>
<div id="anchor-contacts-summary" style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);padding:8px 0">Nenhum contato configurado</div>
@ -1715,11 +1845,255 @@ Hora: {HORA}</textarea>
</div>
<button class="fab" id="fab" onclick="quickAdd()" title="Adicionar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<!-- Fleet Manager Modal -->
<div class="modal-backdrop" id="fleet-modal" onclick="if(event.target===this)closeModal('fleet-modal')">
<div class="modal" style="max-width:520px">
<div class="modal-head">
<h3>⚓ Minha Frota</h3>
<button class="icon-btn" onclick="closeModal('fleet-modal')"></button>
</div>
<div class="modal-body">
<div id="fleet-list" class="fleet-list"></div>
<div class="fleet-units-row">
<span class="fleet-units-label">Unidades</span>
<div class="fleet-units-toggle" id="fleet-units-toggle">
<button type="button" data-unit="metric" onclick="setUnits('metric')">Metros</button>
<button type="button" data-unit="imperial" onclick="setUnits('imperial')">Pés</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn" onclick="closeModal('fleet-modal')">Fechar</button>
<button class="btn btn-primary" onclick="openBoatEditor(null)">+ Nova embarcação</button>
</div>
</div>
</div>
<!-- Boat Editor Modal -->
<div class="modal-backdrop" id="boat-editor-modal" onclick="if(event.target===this)closeModal('boat-editor-modal')">
<div class="modal" style="max-width:480px">
<div class="modal-head">
<h3 id="boat-editor-title">Nova embarcação</h3>
<button class="icon-btn" onclick="closeModal('boat-editor-modal')"></button>
</div>
<div class="modal-body">
<form id="boat-editor-form" onsubmit="saveBoatFromForm(event)">
<input type="hidden" id="boat-edit-id">
<div class="field"><label class="field-label">Nome</label>
<input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta">
</div>
<div class="field"><label class="field-label">Tipo de embarcação</label>
<select id="boat-edit-type">
<option value="sailing">⛵ Veleiro</option>
<option value="motor">🛥️ Lancha / Motor</option>
<option value="catamaran">🚤 Catamarã</option>
<option value="rib">🚣 Bote / RIB</option>
<option value="other">🛳️ Outro</option>
</select>
</div>
<div class="field"><label class="field-label">Modelo / Classe (opcional)</label>
<input type="text" id="boat-edit-model" maxlength="48" placeholder="Beneteau 36, Delta 28...">
</div>
<div class="field-row">
<div class="field"><label class="field-label">Comprimento <span id="boat-len-unit">(m)</span></label>
<input type="number" id="boat-edit-length" step="0.1" min="0" placeholder="0.0">
</div>
<div class="field"><label class="field-label">Boca / Largura <span id="boat-beam-unit">(m)</span></label>
<input type="number" id="boat-edit-beam" step="0.1" min="0" placeholder="0.0">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Calado <span id="boat-draft-unit">(m)</span></label>
<input type="number" id="boat-edit-draft" step="0.1" min="0" placeholder="profundidade casco">
</div>
<div class="field"><label class="field-label">Amarra a bordo <span id="boat-chain-unit">(m)</span></label>
<input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível">
</div>
</div>
<div class="field"><label class="field-label">Ano (opcional)</label>
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018">
</div>
<div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div>
</form>
</div>
<div class="modal-foot">
<button class="btn btn-danger" id="boat-edit-delete" onclick="deleteBoatFromEditor()" style="display:none">Excluir</button>
<button class="btn" onclick="closeModal('boat-editor-modal')">Cancelar</button>
<button class="btn btn-primary" onclick="document.getElementById('boat-editor-form').requestSubmit()">Salvar</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="zone-toast" id="zone-toast"></div>
<script>
const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
// ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display.
const FT_PER_M=3.28084;
const M_PER_FT=0.3048;
function isImperial(){return state.units==='imperial'}
function lengthUnit(){return isImperial()?'ft':'m'}
// Converte storage (sempre metros) → display
function lenToDisplay(meters){if(meters==null||isNaN(meters))return null;return isImperial()?meters*FT_PER_M:meters}
// Converte display (que usuário digitou) → storage (metros)
function lenFromInput(value){if(value==null||value==='')return null;const n=parseFloat(value);if(isNaN(n))return null;return isImperial()?n*M_PER_FT:n}
function fmtLen(meters,decimals=1){if(meters==null||isNaN(meters))return '—';return lenToDisplay(meters).toFixed(decimals)+' '+lengthUnit()}
// Tipos de embarcação com defaults sensatos
const BOAT_TYPES={
sailing:{label:'Veleiro',icon:'⛵',defaultBeamRatio:0.30,defaultDraftRatio:0.18,scopeBonus:0},
motor:{label:'Motor',icon:'🛥️',defaultBeamRatio:0.32,defaultDraftRatio:0.10,scopeBonus:-0.5},
catamaran:{label:'Catamarã',icon:'🚤',defaultBeamRatio:0.55,defaultDraftRatio:0.08,scopeBonus:-0.5},
rib:{label:'Bote/RIB',icon:'🚣',defaultBeamRatio:0.35,defaultDraftRatio:0.06,scopeBonus:-1},
other:{label:'Outro',icon:'🛳️',defaultBeamRatio:0.28,defaultDraftRatio:0.15,scopeBonus:0},
};
// Migration: state.boat (antigo) → state.boats[0] (novo)
function migrateBoatsSchema(){
if(!state.boats||!Array.isArray(state.boats))state.boats=[];
if(state.boats.length===0&&state.boat&&state.boat.name){
const b={
id:uid(),
name:state.boat.name||'Shivao',
model:state.boat.model||'',
type:'sailing',
length:null,beam:null,draft:null,
chainTotal:null,
year:null,
createdAt:Date.now(),
};
state.boats.push(b);
state.activeBoatId=b.id;
}
if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id;
if(!state.units)state.units='metric';
}
function activeBoat(){
return state.boats.find(b=>b.id===state.activeBoatId)||state.boats[0]||null;
}
function setActiveBoat(id){
state.activeBoatId=id;
saveState();
bindHeader();
renderAll();
}
function addBoat(data){
const b={
id:uid(),
name:data.name||'Embarcação',
model:data.model||'',
type:data.type||'sailing',
length:lenFromInput(data.length),
beam:lenFromInput(data.beam),
draft:lenFromInput(data.draft),
chainTotal:lenFromInput(data.chainTotal),
year:data.year?parseInt(data.year):null,
createdAt:Date.now(),
};
state.boats.push(b);
if(!state.activeBoatId)state.activeBoatId=b.id;
saveState();
return b;
}
function updateBoat(id,data){
const b=state.boats.find(x=>x.id===id);
if(!b)return;
if('name' in data)b.name=data.name;
if('model' in data)b.model=data.model;
if('type' in data)b.type=data.type;
if('length' in data)b.length=lenFromInput(data.length);
if('beam' in data)b.beam=lenFromInput(data.beam);
if('draft' in data)b.draft=lenFromInput(data.draft);
if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal);
if('year' in data)b.year=data.year?parseInt(data.year):null;
saveState();
}
function removeBoat(id){
if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return}
state.boats=state.boats.filter(b=>b.id!==id);
if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null;
saveState();
}
// ============ ANCHOR CALCULATIONS (regras náuticas) ============
// Scope ratio = comprimento_amarra / profundidade
// Recomendado: 5:1 normal, 7:1 vento moderado (15-25kn), 10:1 tempestade (>25kn)
function scopeRatio(chainDeployedM,depthM){
if(!depthM||depthM<=0)return null;
return chainDeployedM/depthM;
}
// Raio efetivo de giro = √(amarra² profundidade²) + comprimento_do_barco
// Esse é o círculo dentro do qual a popa do barco pode oscilar com a corrente/vento
function calcSwingRadius(chainDeployedM,depthM,boatLengthM){
if(!chainDeployedM||!depthM||chainDeployedM<=depthM)return null;
const horizontal=Math.sqrt(chainDeployedM*chainDeployedM-depthM*depthM);
return horizontal+(boatLengthM||0);
}
// Recomenda comprimento de amarra baseado em condição
// windKn: vento em nós; depth: profundidade
// Retorna {chainM, ratio, condition}
function recommendChain(depthM,windKn,boatType){
let baseRatio=5; // calmo
let condition='Condição calma';
if(windKn>=25){baseRatio=10;condition='Tempestade'}
else if(windKn>=15){baseRatio=7;condition='Vento moderado'}
else if(windKn>=8){baseRatio=6;condition='Vento leve'}
// Ajuste por tipo de embarcação
const t=BOAT_TYPES[boatType]||BOAT_TYPES.sailing;
const ratio=Math.max(3,baseRatio+t.scopeBonus);
return {chainM:depthM*ratio,ratio,condition};
}
// Status visual da configuração atual de fundeio
function anchorStatus(chainDeployedM,depthM,windKn){
if(!chainDeployedM||!depthM)return {level:'unknown',msg:'Informe profundidade + amarra'};
const r=scopeRatio(chainDeployedM,depthM);
const minR=windKn>=25?10:windKn>=15?7:5;
if(r>=minR)return {level:'ok',msg:`Adequado (${r.toFixed(1)}:1)`};
if(r>=minR-1)return {level:'warn',msg:`Justo (${r.toFixed(1)}:1) — folgar mais ${((minR-r)*depthM).toFixed(0)}m`};
return {level:'danger',msg:`INSUFICIENTE (${r.toFixed(1)}:1) — necessário mínimo ${minR}:1`};
}
// Recupera velocidade de vento atual (em nós) do cache de weather, qualquer provider
function currentWindKnots(){
try{
if(!weather||!weather.data)return null;
const d=weather.data;
if(d.provider==='openmeteo'){
return d.forecast?.current?.wind_speed_10m??null;
}
if(d.provider==='windy'){
const m=d.main;
const u=m?.['wind_u-surface']?.[0];
const v=m?.['wind_v-surface']?.[0];
if(u==null||v==null)return null;
return Math.sqrt(u*u+v*v)*1.94384;
}
}catch(e){}
return null;
}
// Dica geral baseada em vento + condição
function anchorAdvice(windKn,boatType){
const t=BOAT_TYPES[boatType]||BOAT_TYPES.sailing;
if(windKn>=30)return `🌊 Tempestade · scope mínimo 10:1, considere segunda âncora`;
if(windKn>=20)return `🌬️ Vento forte · scope ${t.scopeBonus<0?'7':'8'}:1 + reforçar amarras de bordo`;
if(windKn>=12)return `💨 Vento moderado · scope 7:1 ${t.label==='Catamarã'?'(catamarã: vigiar deriva lateral)':''}`;
if(windKn>=5)return `🍃 Vento leve · scope 5-6:1 padrão`;
return `⚓ Calmo · scope mínimo 5:1`;
}
const STORAGE_KEY='diario_bordo_v3';
const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3';
@ -1736,7 +2110,8 @@ function dbPut(item){return new Promise((r,j)=>{const q=db.transaction('media','
function dbDelete(id){return new Promise((r,j)=>{const q=db.transaction('media','readwrite').objectStore('media').delete(id);q.onsuccess=()=>r();q.onerror=()=>j(q.error)})}
function dbAll(){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').getAll();q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'}}}catch(e){console.warn(e)}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'};state.boats=d.boats||[];state.activeBoatId=d.activeBoatId||null;state.units=d.units||'metric'}}catch(e){console.warn(e)}
migrateBoatsSchema();
// popular checklists padrão se vazio
if(!state.checklists.length){
state.checklists=[
@ -1865,7 +2240,142 @@ function renderGPSBanner(){
}
}
function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
function bindHeader(){
const nameEl=document.getElementById('boat-name-display');
const metaEl=document.getElementById('boat-model-display');
if(!nameEl||!metaEl)return;
const b=activeBoat();
if(!b){nameEl.textContent='Sem embarcação';metaEl.textContent='Toque para adicionar';return}
nameEl.textContent=b.name||'Embarcação';
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const parts=[`${t.icon} ${t.label}`];
if(b.model)parts.push(b.model);
if(b.length)parts.push(fmtLen(b.length,1));
metaEl.textContent=parts.join(' · ');
// sync legacy state.boat para compat com qualquer código antigo que ainda use
state.boat={name:b.name,model:b.model};
}
// ============ FLEET MANAGER ============
function openFleetManager(){
renderFleetList();
syncUnitsToggle();
openModal('fleet-modal');
}
function renderFleetList(){
const el=document.getElementById('fleet-list');
if(!el)return;
if(!state.boats||state.boats.length===0){
el.innerHTML=`<div class="fleet-empty">Nenhuma embarcação ainda.<br>Toque "+ Nova" pra começar.</div>`;
return;
}
el.innerHTML=state.boats.map(b=>{
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const isActive=b.id===state.activeBoatId;
const meta=[t.label];
if(b.model)meta.push(b.model);
if(b.length)meta.push(fmtLen(b.length,1));
return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')">
<div class="fleet-icon">${t.icon}</div>
<div class="fleet-info">
<div class="fleet-name">${escapeHtml(b.name)}</div>
<div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div>
</div>
${isActive?'<div class="fleet-active-badge">ATIVA</div>':''}
<button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar"></button>
</div>`;
}).join('');
}
function setActiveBoatAndClose(id){
setActiveBoat(id);
renderFleetList();
toast('Embarcação ativa: '+(activeBoat()?.name||''));
}
function openBoatEditor(id){
const isNew=!id;
document.getElementById('boat-editor-title').textContent=isNew?'Nova embarcação':'Editar embarcação';
document.getElementById('boat-edit-id').value=id||'';
const b=isNew?{}:state.boats.find(x=>x.id===id)||{};
document.getElementById('boat-edit-name').value=b.name||'';
document.getElementById('boat-edit-type').value=b.type||'sailing';
document.getElementById('boat-edit-model').value=b.model||'';
// Mostrar valores convertidos pra unidade atual
const setLen=(elId,m)=>{const el=document.getElementById(elId);el.value=m==null?'':lenToDisplay(m).toFixed(1)};
setLen('boat-edit-length',b.length);
setLen('boat-edit-beam',b.beam);
setLen('boat-edit-draft',b.draft);
// Amarra usa metros inteiros normalmente
const chainEl=document.getElementById('boat-edit-chain');
chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0);
document.getElementById('boat-edit-year').value=b.year||'';
// Atualizar labels de unidade
const unitLabel=`(${lengthUnit()})`;
['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{
const el=document.getElementById(id);if(el)el.textContent=unitLabel;
});
// Botão excluir só aparece em edição e se houver mais de 1 barco
document.getElementById('boat-edit-delete').style.display=(!isNew&&state.boats.length>1)?'inline-flex':'none';
closeModal('fleet-modal');
openModal('boat-editor-modal');
setTimeout(()=>document.getElementById('boat-edit-name').focus(),100);
}
function saveBoatFromForm(ev){
ev.preventDefault();
const id=document.getElementById('boat-edit-id').value;
const data={
name:document.getElementById('boat-edit-name').value.trim(),
type:document.getElementById('boat-edit-type').value,
model:document.getElementById('boat-edit-model').value.trim(),
length:document.getElementById('boat-edit-length').value,
beam:document.getElementById('boat-edit-beam').value,
draft:document.getElementById('boat-edit-draft').value,
chainTotal:document.getElementById('boat-edit-chain').value,
year:document.getElementById('boat-edit-year').value,
};
if(!data.name){toast('Informe o nome da embarcação');return}
if(id){updateBoat(id,data);toast('Embarcação atualizada')}
else{const b=addBoat(data);state.activeBoatId=b.id;saveState();toast('Embarcação adicionada')}
closeModal('boat-editor-modal');
bindHeader();
renderAll();
openFleetManager();
}
function deleteBoatFromEditor(){
const id=document.getElementById('boat-edit-id').value;
if(!id)return;
const b=state.boats.find(x=>x.id===id);
if(!b)return;
if(!confirm(`Excluir "${b.name}"? Esta ação não pode ser desfeita.`))return;
removeBoat(id);
closeModal('boat-editor-modal');
bindHeader();
renderAll();
openFleetManager();
toast('Embarcação removida');
}
function setUnits(u){
if(u!=='metric'&&u!=='imperial')return;
state.units=u;
saveState();
syncUnitsToggle();
bindHeader();
renderAll();
toast(u==='metric'?'Unidade: metros':'Unidade: pés');
}
function syncUnitsToggle(){
const wrap=document.getElementById('fleet-units-toggle');
if(!wrap)return;
wrap.querySelectorAll('button').forEach(b=>{
b.classList.toggle('active',b.dataset.unit===state.units);
});
}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
@ -2279,6 +2789,7 @@ async function openAnchorSetup(){
updateRadiusLabel(50);
pendingAnchorPos=null;
updateContactsSummary();
initAnchorCalc();
openModal('anchor-setup-modal');
// tentar obter posição
navigator.geolocation.getCurrentPosition(
@ -2293,6 +2804,105 @@ async function openAnchorSetup(){
);
}
// ============ ANCHOR CALCULATOR (modal) ============
let anchorCalcLastRecommendedRadius=null;
function initAnchorCalc(){
// Atualiza label de embarcação no título
const b=activeBoat();
const titleEl=document.getElementById('anchor-modal-title');
if(titleEl)titleEl.textContent=b?`Fundear · ${b.name}`:'Fundear';
// Atualiza unidades nos labels
const u=`(${lengthUnit()})`;
const dEl=document.getElementById('anchor-depth-unit');if(dEl)dEl.textContent=u;
const cEl=document.getElementById('anchor-chain-unit');if(cEl)cEl.textContent=u;
// Reset inputs
document.getElementById('anchor-depth').value='';
document.getElementById('anchor-chain').value='';
// Pré-popular vento se Windy/OpenMeteo já fetchou
const wKn=currentWindKnots();
const wEl=document.getElementById('anchor-wind');
const srcEl=document.getElementById('anchor-wind-source');
if(wKn!=null){
wEl.value=Math.round(wKn);
if(srcEl)srcEl.textContent=`auto: ${weather.data?.provider==='windy'?'Windy':'Open-Meteo'}`;
}else{
wEl.value='';
if(srcEl)srcEl.textContent='manual (sem GPS na meteo ainda)';
}
document.getElementById('anchor-apply-recommend').style.display='none';
document.getElementById('anchor-calc-result').innerHTML='';
recalcAnchor();
}
function recalcAnchor(){
const depthDisplay=parseFloat(document.getElementById('anchor-depth').value);
const chainDisplay=parseFloat(document.getElementById('anchor-chain').value);
const windKn=parseFloat(document.getElementById('anchor-wind').value)||0;
const depth=isNaN(depthDisplay)?null:lenFromInput(depthDisplay);
const chain=isNaN(chainDisplay)?null:lenFromInput(chainDisplay);
const b=activeBoat();
const boatLength=b?.length||0;
const boatType=b?.type||'sailing';
const out=document.getElementById('anchor-calc-result');
if(!depth){
out.classList.add('full');
out.innerHTML=`<div class="anchor-calc-advice">${anchorAdvice(windKn,boatType)}<br><small style="color:var(--sepia)">Informe a profundidade pra calcular.</small></div>`;
document.getElementById('anchor-apply-recommend').style.display='none';
return;
}
out.classList.remove('full');
const rec=recommendChain(depth,windKn,boatType);
const status=anchorStatus(chain,depth,windKn);
const swing=calcSwingRadius(chain,depth,boatLength);
// Sugestão de raio: swing radius + buffer GPS (15m) ou se não tiver chain, baseado em recomendação
const recommendedRadiusM=swing
? Math.ceil(swing+15)
: Math.ceil(Math.sqrt(Math.max(0,rec.chainM*rec.chainM-depth*depth))+boatLength+15);
anchorCalcLastRecommendedRadius=recommendedRadiusM;
const advice=anchorAdvice(windKn,boatType);
const adviceClass=status.level==='danger'?'danger':status.level==='warn'?'warn':status.level==='ok'?'ok':'';
out.innerHTML=`
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Amarra recomendada</div>
<div class="anchor-calc-stat-value">${fmtLen(rec.chainM,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">${rec.ratio.toFixed(1)}:1 · ${rec.condition}</div>
</div>
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Status atual</div>
<div class="anchor-calc-stat-value ${status.level}">${status.level==='ok'?'✓ OK':status.level==='warn'?'⚠ JUSTO':status.level==='danger'?'✗ INSUF.':'—'}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">${escapeHtml(status.msg)}</div>
</div>
${swing?`
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Raio de giro</div>
<div class="anchor-calc-stat-value">${fmtLen(swing,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">amarra→popa + barco</div>
</div>
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Raio sugerido (alarme)</div>
<div class="anchor-calc-stat-value">${fmtLen(recommendedRadiusM,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">+15m buffer GPS</div>
</div>`:''}
<div class="anchor-calc-advice ${adviceClass}">${advice}${b?` · <strong>${BOAT_TYPES[boatType].icon} ${b.name}</strong> ${b.length?`(${fmtLen(b.length,1)})`:''}`:''}</div>
`;
document.getElementById('anchor-apply-recommend').style.display=swing?'inline-flex':'none';
}
function applyRecommendedRadius(){
if(!anchorCalcLastRecommendedRadius)return;
// Slider só aceita 15-200m
const v=Math.max(15,Math.min(200,anchorCalcLastRecommendedRadius));
document.getElementById('anchor-radius').value=v;
updateRadiusLabel(v);
toast(`Raio de alarme ajustado para ${v}m`);
}
function updateRadiusLabel(v){document.getElementById('anchor-radius-val').textContent=`${v} m`}
function updateContactsSummary(){
const el=document.getElementById('anchor-contacts-summary');

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionName "1.3.0"
versionCode 4
versionName "1.4.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -89,11 +89,29 @@ header{
padding:1px 0;width:100%;
}
.boat-name:focus{outline:none;border-bottom:1px dashed var(--brass)}
.boat-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.06em;color:rgba(250,242,221,.65);margin-top:2px}
.boat-selector{
display:inline-flex;align-items:center;gap:6px;cursor:pointer;
text-align:left;width:auto;
transition:opacity .15s;
}
.boat-selector:hover{opacity:.8}
.boat-selector:active{transform:scale(.98)}
.boat-chevron{font-size:14px;opacity:.6;font-style:normal;transform:translateY(1px)}
.boat-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.06em;color:rgba(250,242,221,.65);margin-top:2px;display:flex;align-items:center;gap:8px}
.boat-meta input{
background:transparent;border:none;color:inherit;
font:inherit;padding:1px 0;width:100%;
}
.boat-model-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.boat-edit-btn{
background:rgba(250,242,221,.08);border:1px solid rgba(250,242,221,.18);
color:rgba(250,242,221,.7);cursor:pointer;
width:22px;height:22px;border-radius:6px;
display:inline-flex;align-items:center;justify-content:center;
font-size:11px;flex-shrink:0;
transition:all .15s;
}
.boat-edit-btn:hover{background:rgba(250,242,221,.15);color:var(--bg-paper)}
.boat-meta input::placeholder{color:rgba(250,242,221,.4)}
.boat-meta input:focus{outline:none;border-bottom:1px dashed var(--brass)}
.boat-coord{font-family:var(--f-mono);font-size:9.5px;color:var(--brass-bright);letter-spacing:.1em;text-align:right;flex-shrink:0;line-height:1.5}
@ -465,6 +483,93 @@ header{
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.field-hint{font-size:10px;color:var(--sepia);margin-top:4px;font-style:italic;font-family:var(--f-display)}
/* === Fleet Manager === */
.fleet-list{display:flex;flex-direction:column;gap:8px;margin-bottom:18px;max-height:50vh;overflow-y:auto}
.fleet-item{
display:flex;align-items:center;gap:12px;
padding:12px 14px;background:var(--bg-canvas);
border:1px solid var(--rule);cursor:pointer;
transition:all .15s;
}
.fleet-item:hover{background:var(--bg-aged);border-color:var(--sepia)}
.fleet-item.active{border-left:3px solid var(--brass);background:var(--bg-aged)}
.fleet-icon{font-size:24px;flex-shrink:0;width:32px;text-align:center}
.fleet-info{flex:1;min-width:0}
.fleet-name{font-family:var(--f-display);font-style:italic;font-size:17px;color:var(--ink-deep);font-weight:500}
.fleet-meta{font-family:var(--f-mono);font-size:10px;letter-spacing:.06em;color:var(--sepia);margin-top:2px;text-transform:uppercase}
.fleet-active-badge{
background:var(--brass);color:var(--bg-paper);
font-family:var(--f-mono);font-size:9px;letter-spacing:.12em;
padding:3px 8px;text-transform:uppercase;
}
.fleet-edit-icon{
background:transparent;border:1px solid var(--rule);
width:28px;height:28px;display:flex;align-items:center;justify-content:center;
cursor:pointer;color:var(--sepia);font-size:12px;flex-shrink:0;
}
.fleet-edit-icon:hover{border-color:var(--brass);color:var(--brass)}
.fleet-empty{
text-align:center;padding:24px;color:var(--sepia);
font-family:var(--f-display);font-style:italic;font-size:14px;
border:1px dashed var(--rule);
}
.fleet-units-row{
display:flex;align-items:center;justify-content:space-between;
padding:14px 0 0;border-top:1px solid var(--rule);
}
.fleet-units-label{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--sepia)}
.fleet-units-toggle{display:flex;border:1px solid var(--rule)}
.fleet-units-toggle button{
background:var(--bg-canvas);border:none;
padding:7px 14px;font-family:var(--f-mono);font-size:11px;
letter-spacing:.08em;cursor:pointer;color:var(--ink-mid);
transition:all .15s;
}
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Anchor Calculator === */
.anchor-calc{
background:var(--bg-canvas);border:1px solid var(--rule);
border-left:3px solid var(--ocean);
padding:14px;margin:14px 0;
}
.anchor-calc-head{
font-family:var(--f-mono);font-size:11px;letter-spacing:.14em;
text-transform:uppercase;color:var(--ocean);
margin-bottom:10px;font-weight:600;
}
.anchor-calc .field{margin-bottom:10px}
.anchor-calc-result{
display:grid;grid-template-columns:1fr 1fr;gap:8px;
margin-top:10px;
}
.anchor-calc-result.full{grid-template-columns:1fr}
.anchor-calc-stat{
background:var(--bg-paper);padding:10px;border:1px solid var(--rule-soft);
}
.anchor-calc-stat-label{
font-family:var(--f-mono);font-size:9.5px;letter-spacing:.14em;
text-transform:uppercase;color:var(--sepia);margin-bottom:4px;
}
.anchor-calc-stat-value{
font-family:var(--f-display);font-style:italic;font-size:20px;
color:var(--ink-deep);font-weight:500;line-height:1;
}
.anchor-calc-stat-value.ok{color:var(--algae,#4a8c4a)}
.anchor-calc-stat-value.warn{color:var(--sun,#c8943a)}
.anchor-calc-stat-value.danger{color:var(--storm,#b04040)}
.anchor-calc-advice{
grid-column:1/-1;
padding:10px 12px;background:var(--ocean-soft,rgba(50,90,140,.08));
border-left:2px solid var(--ocean);
font-family:var(--f-display);font-style:italic;font-size:13px;
color:var(--ink-deep);line-height:1.5;
}
.anchor-calc-advice.danger{background:var(--storm-soft,rgba(176,64,64,.08));border-color:var(--storm)}
.anchor-calc-advice.warn{background:var(--sun-soft,rgba(200,148,58,.1));border-color:var(--sun)}
.anchor-calc-advice.ok{background:rgba(74,140,74,.08);border-color:var(--algae,#4a8c4a)}
.pax-input-row{display:flex;gap:6px}
.pax-input-row input{flex:1}
.pax-list{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;min-height:24px}
@ -1235,8 +1340,14 @@ header{
</svg>
<div class="boat-info">
<div class="boat-tagline">Diário de Bordo · Logbook</div>
<input class="boat-name" id="boat-name" value="Shivao" maxlength="40" spellcheck="false">
<div class="boat-meta"><input id="boat-model" placeholder="Modelo / Classe / Marina" maxlength="48"></div>
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
<span id="boat-name-display">Shivao</span>
<span class="boat-chevron" aria-hidden="true"></span>
</button>
<div class="boat-meta">
<span id="boat-model-display" class="boat-model-text">⛵ Veleiro</span>
<button type="button" class="boat-edit-btn" onclick="openFleetManager()" title="Editar embarcação"></button>
</div>
</div>
</div>
</header>
@ -1513,19 +1624,38 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<!-- Anchor Setup Modal -->
<div class="modal-backdrop" id="anchor-setup-modal">
<div class="modal">
<div class="modal-head"><h3>Fundear o Shivao</h3><button class="icon-btn" onclick="closeModal('anchor-setup-modal')"></button></div>
<div class="modal-head"><h3 id="anchor-modal-title">Fundear</h3><button class="icon-btn" onclick="closeModal('anchor-setup-modal')"></button></div>
<div class="modal-body">
<div id="anchor-setup-status" style="font-family:var(--f-display);font-style:italic;font-size:14px;color:var(--sepia);margin-bottom:14px;text-align:center;padding:16px;background:var(--bg-canvas);border:1px dashed var(--rule)">Aguardando posição do GPS…</div>
<div class="field"><label class="field-label">Posição registrada</label>
<div id="anchor-coord" style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);padding:10px 12px;background:var(--bg-canvas);border:1px solid var(--rule)"></div>
</div>
<!-- Anchor Calculator -->
<div class="anchor-calc">
<div class="anchor-calc-head">⚓ Calculadora de fundeio</div>
<div class="field-row">
<div class="field"><label class="field-label">Profundidade <span id="anchor-depth-unit">(m)</span></label>
<input type="number" id="anchor-depth" step="0.5" min="0" placeholder="0.0" oninput="recalcAnchor()">
</div>
<div class="field"><label class="field-label">Amarra lançada <span id="anchor-chain-unit">(m)</span></label>
<input type="number" id="anchor-chain" step="1" min="0" placeholder="0" oninput="recalcAnchor()">
</div>
</div>
<div class="field"><label class="field-label">Vento atual (nós) <span id="anchor-wind-source" style="font-weight:400;color:var(--sepia);text-transform:none;letter-spacing:0">manual</span></label>
<input type="number" id="anchor-wind" step="1" min="0" placeholder="ex: 12" oninput="recalcAnchor()">
</div>
<div id="anchor-calc-result" class="anchor-calc-result"></div>
<button type="button" class="btn btn-block btn-sm" onclick="applyRecommendedRadius()" id="anchor-apply-recommend" style="display:none;margin-top:8px">⚓ Aplicar raio sugerido</button>
</div>
<div class="field">
<label class="field-label">Raio de segurança</label>
<label class="field-label">Raio de segurança (alarme)</label>
<div class="anchor-radius-row">
<input type="range" id="anchor-radius" min="15" max="200" step="5" value="50" oninput="updateRadiusLabel(this.value)">
<span class="anchor-radius-val" id="anchor-radius-val">50 m</span>
</div>
<div class="field-hint">Distância máxima da âncora antes de disparar o alarme. Considere: linha de fundeio + maré + giro do barco.</div>
<div class="field-hint">Distância máxima da âncora antes de disparar o alarme. Use a calculadora acima pra estimar.</div>
</div>
<div class="field"><label class="field-label">Contatos de emergência</label>
<div id="anchor-contacts-summary" style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);padding:8px 0">Nenhum contato configurado</div>
@ -1715,11 +1845,255 @@ Hora: {HORA}</textarea>
</div>
<button class="fab" id="fab" onclick="quickAdd()" title="Adicionar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<!-- Fleet Manager Modal -->
<div class="modal-backdrop" id="fleet-modal" onclick="if(event.target===this)closeModal('fleet-modal')">
<div class="modal" style="max-width:520px">
<div class="modal-head">
<h3>⚓ Minha Frota</h3>
<button class="icon-btn" onclick="closeModal('fleet-modal')"></button>
</div>
<div class="modal-body">
<div id="fleet-list" class="fleet-list"></div>
<div class="fleet-units-row">
<span class="fleet-units-label">Unidades</span>
<div class="fleet-units-toggle" id="fleet-units-toggle">
<button type="button" data-unit="metric" onclick="setUnits('metric')">Metros</button>
<button type="button" data-unit="imperial" onclick="setUnits('imperial')">Pés</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn" onclick="closeModal('fleet-modal')">Fechar</button>
<button class="btn btn-primary" onclick="openBoatEditor(null)">+ Nova embarcação</button>
</div>
</div>
</div>
<!-- Boat Editor Modal -->
<div class="modal-backdrop" id="boat-editor-modal" onclick="if(event.target===this)closeModal('boat-editor-modal')">
<div class="modal" style="max-width:480px">
<div class="modal-head">
<h3 id="boat-editor-title">Nova embarcação</h3>
<button class="icon-btn" onclick="closeModal('boat-editor-modal')"></button>
</div>
<div class="modal-body">
<form id="boat-editor-form" onsubmit="saveBoatFromForm(event)">
<input type="hidden" id="boat-edit-id">
<div class="field"><label class="field-label">Nome</label>
<input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta">
</div>
<div class="field"><label class="field-label">Tipo de embarcação</label>
<select id="boat-edit-type">
<option value="sailing">⛵ Veleiro</option>
<option value="motor">🛥️ Lancha / Motor</option>
<option value="catamaran">🚤 Catamarã</option>
<option value="rib">🚣 Bote / RIB</option>
<option value="other">🛳️ Outro</option>
</select>
</div>
<div class="field"><label class="field-label">Modelo / Classe (opcional)</label>
<input type="text" id="boat-edit-model" maxlength="48" placeholder="Beneteau 36, Delta 28...">
</div>
<div class="field-row">
<div class="field"><label class="field-label">Comprimento <span id="boat-len-unit">(m)</span></label>
<input type="number" id="boat-edit-length" step="0.1" min="0" placeholder="0.0">
</div>
<div class="field"><label class="field-label">Boca / Largura <span id="boat-beam-unit">(m)</span></label>
<input type="number" id="boat-edit-beam" step="0.1" min="0" placeholder="0.0">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Calado <span id="boat-draft-unit">(m)</span></label>
<input type="number" id="boat-edit-draft" step="0.1" min="0" placeholder="profundidade casco">
</div>
<div class="field"><label class="field-label">Amarra a bordo <span id="boat-chain-unit">(m)</span></label>
<input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível">
</div>
</div>
<div class="field"><label class="field-label">Ano (opcional)</label>
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018">
</div>
<div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div>
</form>
</div>
<div class="modal-foot">
<button class="btn btn-danger" id="boat-edit-delete" onclick="deleteBoatFromEditor()" style="display:none">Excluir</button>
<button class="btn" onclick="closeModal('boat-editor-modal')">Cancelar</button>
<button class="btn btn-primary" onclick="document.getElementById('boat-editor-form').requestSubmit()">Salvar</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="zone-toast" id="zone-toast"></div>
<script>
const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
// ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display.
const FT_PER_M=3.28084;
const M_PER_FT=0.3048;
function isImperial(){return state.units==='imperial'}
function lengthUnit(){return isImperial()?'ft':'m'}
// Converte storage (sempre metros) → display
function lenToDisplay(meters){if(meters==null||isNaN(meters))return null;return isImperial()?meters*FT_PER_M:meters}
// Converte display (que usuário digitou) → storage (metros)
function lenFromInput(value){if(value==null||value==='')return null;const n=parseFloat(value);if(isNaN(n))return null;return isImperial()?n*M_PER_FT:n}
function fmtLen(meters,decimals=1){if(meters==null||isNaN(meters))return '—';return lenToDisplay(meters).toFixed(decimals)+' '+lengthUnit()}
// Tipos de embarcação com defaults sensatos
const BOAT_TYPES={
sailing:{label:'Veleiro',icon:'⛵',defaultBeamRatio:0.30,defaultDraftRatio:0.18,scopeBonus:0},
motor:{label:'Motor',icon:'🛥️',defaultBeamRatio:0.32,defaultDraftRatio:0.10,scopeBonus:-0.5},
catamaran:{label:'Catamarã',icon:'🚤',defaultBeamRatio:0.55,defaultDraftRatio:0.08,scopeBonus:-0.5},
rib:{label:'Bote/RIB',icon:'🚣',defaultBeamRatio:0.35,defaultDraftRatio:0.06,scopeBonus:-1},
other:{label:'Outro',icon:'🛳️',defaultBeamRatio:0.28,defaultDraftRatio:0.15,scopeBonus:0},
};
// Migration: state.boat (antigo) → state.boats[0] (novo)
function migrateBoatsSchema(){
if(!state.boats||!Array.isArray(state.boats))state.boats=[];
if(state.boats.length===0&&state.boat&&state.boat.name){
const b={
id:uid(),
name:state.boat.name||'Shivao',
model:state.boat.model||'',
type:'sailing',
length:null,beam:null,draft:null,
chainTotal:null,
year:null,
createdAt:Date.now(),
};
state.boats.push(b);
state.activeBoatId=b.id;
}
if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id;
if(!state.units)state.units='metric';
}
function activeBoat(){
return state.boats.find(b=>b.id===state.activeBoatId)||state.boats[0]||null;
}
function setActiveBoat(id){
state.activeBoatId=id;
saveState();
bindHeader();
renderAll();
}
function addBoat(data){
const b={
id:uid(),
name:data.name||'Embarcação',
model:data.model||'',
type:data.type||'sailing',
length:lenFromInput(data.length),
beam:lenFromInput(data.beam),
draft:lenFromInput(data.draft),
chainTotal:lenFromInput(data.chainTotal),
year:data.year?parseInt(data.year):null,
createdAt:Date.now(),
};
state.boats.push(b);
if(!state.activeBoatId)state.activeBoatId=b.id;
saveState();
return b;
}
function updateBoat(id,data){
const b=state.boats.find(x=>x.id===id);
if(!b)return;
if('name' in data)b.name=data.name;
if('model' in data)b.model=data.model;
if('type' in data)b.type=data.type;
if('length' in data)b.length=lenFromInput(data.length);
if('beam' in data)b.beam=lenFromInput(data.beam);
if('draft' in data)b.draft=lenFromInput(data.draft);
if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal);
if('year' in data)b.year=data.year?parseInt(data.year):null;
saveState();
}
function removeBoat(id){
if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return}
state.boats=state.boats.filter(b=>b.id!==id);
if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null;
saveState();
}
// ============ ANCHOR CALCULATIONS (regras náuticas) ============
// Scope ratio = comprimento_amarra / profundidade
// Recomendado: 5:1 normal, 7:1 vento moderado (15-25kn), 10:1 tempestade (>25kn)
function scopeRatio(chainDeployedM,depthM){
if(!depthM||depthM<=0)return null;
return chainDeployedM/depthM;
}
// Raio efetivo de giro = √(amarra² profundidade²) + comprimento_do_barco
// Esse é o círculo dentro do qual a popa do barco pode oscilar com a corrente/vento
function calcSwingRadius(chainDeployedM,depthM,boatLengthM){
if(!chainDeployedM||!depthM||chainDeployedM<=depthM)return null;
const horizontal=Math.sqrt(chainDeployedM*chainDeployedM-depthM*depthM);
return horizontal+(boatLengthM||0);
}
// Recomenda comprimento de amarra baseado em condição
// windKn: vento em nós; depth: profundidade
// Retorna {chainM, ratio, condition}
function recommendChain(depthM,windKn,boatType){
let baseRatio=5; // calmo
let condition='Condição calma';
if(windKn>=25){baseRatio=10;condition='Tempestade'}
else if(windKn>=15){baseRatio=7;condition='Vento moderado'}
else if(windKn>=8){baseRatio=6;condition='Vento leve'}
// Ajuste por tipo de embarcação
const t=BOAT_TYPES[boatType]||BOAT_TYPES.sailing;
const ratio=Math.max(3,baseRatio+t.scopeBonus);
return {chainM:depthM*ratio,ratio,condition};
}
// Status visual da configuração atual de fundeio
function anchorStatus(chainDeployedM,depthM,windKn){
if(!chainDeployedM||!depthM)return {level:'unknown',msg:'Informe profundidade + amarra'};
const r=scopeRatio(chainDeployedM,depthM);
const minR=windKn>=25?10:windKn>=15?7:5;
if(r>=minR)return {level:'ok',msg:`Adequado (${r.toFixed(1)}:1)`};
if(r>=minR-1)return {level:'warn',msg:`Justo (${r.toFixed(1)}:1) — folgar mais ${((minR-r)*depthM).toFixed(0)}m`};
return {level:'danger',msg:`INSUFICIENTE (${r.toFixed(1)}:1) — necessário mínimo ${minR}:1`};
}
// Recupera velocidade de vento atual (em nós) do cache de weather, qualquer provider
function currentWindKnots(){
try{
if(!weather||!weather.data)return null;
const d=weather.data;
if(d.provider==='openmeteo'){
return d.forecast?.current?.wind_speed_10m??null;
}
if(d.provider==='windy'){
const m=d.main;
const u=m?.['wind_u-surface']?.[0];
const v=m?.['wind_v-surface']?.[0];
if(u==null||v==null)return null;
return Math.sqrt(u*u+v*v)*1.94384;
}
}catch(e){}
return null;
}
// Dica geral baseada em vento + condição
function anchorAdvice(windKn,boatType){
const t=BOAT_TYPES[boatType]||BOAT_TYPES.sailing;
if(windKn>=30)return `🌊 Tempestade · scope mínimo 10:1, considere segunda âncora`;
if(windKn>=20)return `🌬️ Vento forte · scope ${t.scopeBonus<0?'7':'8'}:1 + reforçar amarras de bordo`;
if(windKn>=12)return `💨 Vento moderado · scope 7:1 ${t.label==='Catamarã'?'(catamarã: vigiar deriva lateral)':''}`;
if(windKn>=5)return `🍃 Vento leve · scope 5-6:1 padrão`;
return `⚓ Calmo · scope mínimo 5:1`;
}
const STORAGE_KEY='diario_bordo_v3';
const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3';
@ -1736,7 +2110,8 @@ function dbPut(item){return new Promise((r,j)=>{const q=db.transaction('media','
function dbDelete(id){return new Promise((r,j)=>{const q=db.transaction('media','readwrite').objectStore('media').delete(id);q.onsuccess=()=>r();q.onerror=()=>j(q.error)})}
function dbAll(){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').getAll();q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'}}}catch(e){console.warn(e)}
function loadState(){try{const raw=localStorage.getItem(STORAGE_KEY);if(raw){const d=JSON.parse(raw);Object.assign(state,d);state.pending=d.pending||[];state.passengers=d.passengers||[];state.contacts=d.contacts||[];if(d.alarmTemplate)state.alarmTemplate=d.alarmTemplate;state.cloud=d.cloud||{url:'',token:'',lastSync:0};state.webhooks=d.webhooks||{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}};state.anchorHistory=d.anchorHistory||[];state.zones=d.zones||[];state.checklists=d.checklists||[];state.weatherCfg=d.weatherCfg||{windyKey:'',model:'gfs'};state.boats=d.boats||[];state.activeBoatId=d.activeBoatId||null;state.units=d.units||'metric'}}catch(e){console.warn(e)}
migrateBoatsSchema();
// popular checklists padrão se vazio
if(!state.checklists.length){
state.checklists=[
@ -1865,7 +2240,142 @@ function renderGPSBanner(){
}
}
function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
function bindHeader(){
const nameEl=document.getElementById('boat-name-display');
const metaEl=document.getElementById('boat-model-display');
if(!nameEl||!metaEl)return;
const b=activeBoat();
if(!b){nameEl.textContent='Sem embarcação';metaEl.textContent='Toque para adicionar';return}
nameEl.textContent=b.name||'Embarcação';
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const parts=[`${t.icon} ${t.label}`];
if(b.model)parts.push(b.model);
if(b.length)parts.push(fmtLen(b.length,1));
metaEl.textContent=parts.join(' · ');
// sync legacy state.boat para compat com qualquer código antigo que ainda use
state.boat={name:b.name,model:b.model};
}
// ============ FLEET MANAGER ============
function openFleetManager(){
renderFleetList();
syncUnitsToggle();
openModal('fleet-modal');
}
function renderFleetList(){
const el=document.getElementById('fleet-list');
if(!el)return;
if(!state.boats||state.boats.length===0){
el.innerHTML=`<div class="fleet-empty">Nenhuma embarcação ainda.<br>Toque "+ Nova" pra começar.</div>`;
return;
}
el.innerHTML=state.boats.map(b=>{
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const isActive=b.id===state.activeBoatId;
const meta=[t.label];
if(b.model)meta.push(b.model);
if(b.length)meta.push(fmtLen(b.length,1));
return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')">
<div class="fleet-icon">${t.icon}</div>
<div class="fleet-info">
<div class="fleet-name">${escapeHtml(b.name)}</div>
<div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div>
</div>
${isActive?'<div class="fleet-active-badge">ATIVA</div>':''}
<button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar"></button>
</div>`;
}).join('');
}
function setActiveBoatAndClose(id){
setActiveBoat(id);
renderFleetList();
toast('Embarcação ativa: '+(activeBoat()?.name||''));
}
function openBoatEditor(id){
const isNew=!id;
document.getElementById('boat-editor-title').textContent=isNew?'Nova embarcação':'Editar embarcação';
document.getElementById('boat-edit-id').value=id||'';
const b=isNew?{}:state.boats.find(x=>x.id===id)||{};
document.getElementById('boat-edit-name').value=b.name||'';
document.getElementById('boat-edit-type').value=b.type||'sailing';
document.getElementById('boat-edit-model').value=b.model||'';
// Mostrar valores convertidos pra unidade atual
const setLen=(elId,m)=>{const el=document.getElementById(elId);el.value=m==null?'':lenToDisplay(m).toFixed(1)};
setLen('boat-edit-length',b.length);
setLen('boat-edit-beam',b.beam);
setLen('boat-edit-draft',b.draft);
// Amarra usa metros inteiros normalmente
const chainEl=document.getElementById('boat-edit-chain');
chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0);
document.getElementById('boat-edit-year').value=b.year||'';
// Atualizar labels de unidade
const unitLabel=`(${lengthUnit()})`;
['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{
const el=document.getElementById(id);if(el)el.textContent=unitLabel;
});
// Botão excluir só aparece em edição e se houver mais de 1 barco
document.getElementById('boat-edit-delete').style.display=(!isNew&&state.boats.length>1)?'inline-flex':'none';
closeModal('fleet-modal');
openModal('boat-editor-modal');
setTimeout(()=>document.getElementById('boat-edit-name').focus(),100);
}
function saveBoatFromForm(ev){
ev.preventDefault();
const id=document.getElementById('boat-edit-id').value;
const data={
name:document.getElementById('boat-edit-name').value.trim(),
type:document.getElementById('boat-edit-type').value,
model:document.getElementById('boat-edit-model').value.trim(),
length:document.getElementById('boat-edit-length').value,
beam:document.getElementById('boat-edit-beam').value,
draft:document.getElementById('boat-edit-draft').value,
chainTotal:document.getElementById('boat-edit-chain').value,
year:document.getElementById('boat-edit-year').value,
};
if(!data.name){toast('Informe o nome da embarcação');return}
if(id){updateBoat(id,data);toast('Embarcação atualizada')}
else{const b=addBoat(data);state.activeBoatId=b.id;saveState();toast('Embarcação adicionada')}
closeModal('boat-editor-modal');
bindHeader();
renderAll();
openFleetManager();
}
function deleteBoatFromEditor(){
const id=document.getElementById('boat-edit-id').value;
if(!id)return;
const b=state.boats.find(x=>x.id===id);
if(!b)return;
if(!confirm(`Excluir "${b.name}"? Esta ação não pode ser desfeita.`))return;
removeBoat(id);
closeModal('boat-editor-modal');
bindHeader();
renderAll();
openFleetManager();
toast('Embarcação removida');
}
function setUnits(u){
if(u!=='metric'&&u!=='imperial')return;
state.units=u;
saveState();
syncUnitsToggle();
bindHeader();
renderAll();
toast(u==='metric'?'Unidade: metros':'Unidade: pés');
}
function syncUnitsToggle(){
const wrap=document.getElementById('fleet-units-toggle');
if(!wrap)return;
wrap.querySelectorAll('button').forEach(b=>{
b.classList.toggle('active',b.dataset.unit===state.units);
});
}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
@ -2279,6 +2789,7 @@ async function openAnchorSetup(){
updateRadiusLabel(50);
pendingAnchorPos=null;
updateContactsSummary();
initAnchorCalc();
openModal('anchor-setup-modal');
// tentar obter posição
navigator.geolocation.getCurrentPosition(
@ -2293,6 +2804,105 @@ async function openAnchorSetup(){
);
}
// ============ ANCHOR CALCULATOR (modal) ============
let anchorCalcLastRecommendedRadius=null;
function initAnchorCalc(){
// Atualiza label de embarcação no título
const b=activeBoat();
const titleEl=document.getElementById('anchor-modal-title');
if(titleEl)titleEl.textContent=b?`Fundear · ${b.name}`:'Fundear';
// Atualiza unidades nos labels
const u=`(${lengthUnit()})`;
const dEl=document.getElementById('anchor-depth-unit');if(dEl)dEl.textContent=u;
const cEl=document.getElementById('anchor-chain-unit');if(cEl)cEl.textContent=u;
// Reset inputs
document.getElementById('anchor-depth').value='';
document.getElementById('anchor-chain').value='';
// Pré-popular vento se Windy/OpenMeteo já fetchou
const wKn=currentWindKnots();
const wEl=document.getElementById('anchor-wind');
const srcEl=document.getElementById('anchor-wind-source');
if(wKn!=null){
wEl.value=Math.round(wKn);
if(srcEl)srcEl.textContent=`auto: ${weather.data?.provider==='windy'?'Windy':'Open-Meteo'}`;
}else{
wEl.value='';
if(srcEl)srcEl.textContent='manual (sem GPS na meteo ainda)';
}
document.getElementById('anchor-apply-recommend').style.display='none';
document.getElementById('anchor-calc-result').innerHTML='';
recalcAnchor();
}
function recalcAnchor(){
const depthDisplay=parseFloat(document.getElementById('anchor-depth').value);
const chainDisplay=parseFloat(document.getElementById('anchor-chain').value);
const windKn=parseFloat(document.getElementById('anchor-wind').value)||0;
const depth=isNaN(depthDisplay)?null:lenFromInput(depthDisplay);
const chain=isNaN(chainDisplay)?null:lenFromInput(chainDisplay);
const b=activeBoat();
const boatLength=b?.length||0;
const boatType=b?.type||'sailing';
const out=document.getElementById('anchor-calc-result');
if(!depth){
out.classList.add('full');
out.innerHTML=`<div class="anchor-calc-advice">${anchorAdvice(windKn,boatType)}<br><small style="color:var(--sepia)">Informe a profundidade pra calcular.</small></div>`;
document.getElementById('anchor-apply-recommend').style.display='none';
return;
}
out.classList.remove('full');
const rec=recommendChain(depth,windKn,boatType);
const status=anchorStatus(chain,depth,windKn);
const swing=calcSwingRadius(chain,depth,boatLength);
// Sugestão de raio: swing radius + buffer GPS (15m) ou se não tiver chain, baseado em recomendação
const recommendedRadiusM=swing
? Math.ceil(swing+15)
: Math.ceil(Math.sqrt(Math.max(0,rec.chainM*rec.chainM-depth*depth))+boatLength+15);
anchorCalcLastRecommendedRadius=recommendedRadiusM;
const advice=anchorAdvice(windKn,boatType);
const adviceClass=status.level==='danger'?'danger':status.level==='warn'?'warn':status.level==='ok'?'ok':'';
out.innerHTML=`
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Amarra recomendada</div>
<div class="anchor-calc-stat-value">${fmtLen(rec.chainM,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">${rec.ratio.toFixed(1)}:1 · ${rec.condition}</div>
</div>
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Status atual</div>
<div class="anchor-calc-stat-value ${status.level}">${status.level==='ok'?'✓ OK':status.level==='warn'?'⚠ JUSTO':status.level==='danger'?'✗ INSUF.':'—'}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">${escapeHtml(status.msg)}</div>
</div>
${swing?`
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Raio de giro</div>
<div class="anchor-calc-stat-value">${fmtLen(swing,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">amarra→popa + barco</div>
</div>
<div class="anchor-calc-stat">
<div class="anchor-calc-stat-label">Raio sugerido (alarme)</div>
<div class="anchor-calc-stat-value">${fmtLen(recommendedRadiusM,0)}</div>
<div style="font-family:var(--f-mono);font-size:9.5px;color:var(--sepia);margin-top:3px">+15m buffer GPS</div>
</div>`:''}
<div class="anchor-calc-advice ${adviceClass}">${advice}${b?` · <strong>${BOAT_TYPES[boatType].icon} ${b.name}</strong> ${b.length?`(${fmtLen(b.length,1)})`:''}`:''}</div>
`;
document.getElementById('anchor-apply-recommend').style.display=swing?'inline-flex':'none';
}
function applyRecommendedRadius(){
if(!anchorCalcLastRecommendedRadius)return;
// Slider só aceita 15-200m
const v=Math.max(15,Math.min(200,anchorCalcLastRecommendedRadius));
document.getElementById('anchor-radius').value=v;
updateRadiusLabel(v);
toast(`Raio de alarme ajustado para ${v}m`);
}
function updateRadiusLabel(v){document.getElementById('anchor-radius-val').textContent=`${v} m`}
function updateContactsSummary(){
const el=document.getElementById('anchor-contacts-summary');