- Endpoint Express /manifest.json com name/short_name/icons/theme - /icon.svg vetorial (veleiro estilizado em paleta marítima) - Links manifest + apple-touch-icon nos 2 HTMLs (app/ e server/public/) Habilita: - Android Chrome: 'Add to Home Screen' com ícone bonito - iOS Safari: ícone na tela inicial - PWABuilder: pode gerar APK Android sideload-ready Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3482 lines
201 KiB
HTML
3482 lines
201 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="pt-BR">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<meta name="theme-color" content="#0e2a3d">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-title" content="Diário de Bordo">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<link rel="manifest" href="/manifest.json">
|
||
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
||
<link rel="apple-touch-icon" href="/icon.svg">
|
||
<title>Diário de Bordo</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@0,9..144,400..700,0..100;1,9..144,400..700,0..100&family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg stroke='%230e2a3d' stroke-width='1' fill='none'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 2 L13.5 12 L12 22 L10.5 12 Z' fill='%230e2a3d'/%3E%3Cpath d='M2 12 L12 13.5 L22 12 L12 10.5 Z' fill='%23a07832'/%3E%3C/g%3E%3C/svg%3E">
|
||
<style>
|
||
:root{
|
||
/* paper / ink palette */
|
||
--bg-canvas:#efe5cd;
|
||
--bg-paper:#faf2dd;
|
||
--bg-aged:#e3d4ab;
|
||
--bg-shade:#ebd9a7;
|
||
/* ink */
|
||
--ink-deep:#0e2a3d;
|
||
--ink-mid:#2c4661;
|
||
--ink-soft:#5d7186;
|
||
--sepia:#7d6943;
|
||
--sepia-soft:#a89370;
|
||
/* rules */
|
||
--rule:#b89c6c;
|
||
--rule-soft:#d8c79a;
|
||
--rule-fade:#e6d9b1;
|
||
/* brass */
|
||
--brass:#a07832;
|
||
--brass-bright:#c89f54;
|
||
--brass-deep:#6f5217;
|
||
/* signal */
|
||
--ocean:#1f5b76;
|
||
--algae:#3f7768;
|
||
--algae-soft:#dde9d8;
|
||
--storm:#8c3434;
|
||
--storm-soft:#f0d9d9;
|
||
--sun:#b67025;
|
||
--sun-soft:#f3e3c2;
|
||
/* type */
|
||
--f-display:'Fraunces','Cormorant Garamond',Georgia,serif;
|
||
--f-body:'Manrope',-apple-system,BlinkMacSystemFont,sans-serif;
|
||
--f-mono:'JetBrains Mono','Courier New',monospace;
|
||
}
|
||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||
html,body{margin:0;padding:0;min-height:100vh;overflow-x:hidden;background:var(--bg-canvas);color:var(--ink-deep);font-family:var(--f-body);font-size:15px;line-height:1.55;font-weight:400}
|
||
body{
|
||
padding-bottom:env(safe-area-inset-bottom);
|
||
background-image:
|
||
radial-gradient(ellipse 800px 600px at top right,#f6ecd2 0%,transparent 70%),
|
||
radial-gradient(ellipse 600px 400px at bottom left,#e8dcb8 0%,transparent 70%),
|
||
url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.3 0 0 0 0 0.15 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
|
||
background-attachment:fixed;
|
||
}
|
||
::selection{background:var(--brass-bright);color:var(--ink-deep)}
|
||
|
||
/* ============ HEADER ============ */
|
||
header{
|
||
background:var(--ink-deep);
|
||
color:var(--bg-paper);
|
||
padding:max(18px,env(safe-area-inset-top)) 16px 16px;
|
||
position:sticky;top:0;z-index:40;
|
||
border-bottom:1px solid var(--brass-deep);
|
||
box-shadow:0 1px 0 var(--brass) inset, 0 6px 24px rgba(14,42,61,.18);
|
||
}
|
||
.header-row{display:flex;align-items:center;gap:14px;max-width:780px;margin:0 auto}
|
||
.compass-mark{width:42px;height:42px;flex-shrink:0;color:var(--brass-bright)}
|
||
.boat-info{flex:1;min-width:0}
|
||
.boat-tagline{
|
||
font-family:var(--f-mono);font-size:9.5px;font-weight:500;
|
||
letter-spacing:.28em;text-transform:uppercase;
|
||
color:var(--brass-bright);opacity:.9;margin-bottom:1px;
|
||
}
|
||
.boat-name{
|
||
font-family:var(--f-display);font-style:italic;font-weight:500;
|
||
font-variation-settings:"opsz" 60,"SOFT" 50;
|
||
font-size:24px;line-height:1.05;letter-spacing:-.01em;
|
||
background:transparent;border:none;color:var(--bg-paper);
|
||
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-meta input{
|
||
background:transparent;border:none;color:inherit;
|
||
font:inherit;padding:1px 0;width:100%;
|
||
}
|
||
.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}
|
||
|
||
/* ============ LAYOUT ============ */
|
||
.container{max-width:780px;margin:0 auto;padding:18px 14px 96px}
|
||
.tabs{
|
||
display:flex;gap:0;
|
||
border-bottom:1px solid var(--rule);
|
||
margin-bottom:22px;
|
||
overflow-x:auto;scrollbar-width:none;
|
||
position:sticky;top:78px;z-index:30;
|
||
background:linear-gradient(to bottom,var(--bg-canvas) 0%,var(--bg-canvas) 92%,transparent 100%);
|
||
padding-top:6px;
|
||
}
|
||
.tabs::-webkit-scrollbar{display:none}
|
||
.tab{
|
||
flex:1 0 auto;padding:11px 14px 12px;
|
||
background:none;border:none;
|
||
font-family:var(--f-mono);font-size:10.5px;font-weight:500;
|
||
letter-spacing:.18em;text-transform:uppercase;
|
||
color:var(--sepia);cursor:pointer;
|
||
position:relative;white-space:nowrap;
|
||
transition:color .15s;
|
||
}
|
||
.tab.active{color:var(--ink-deep)}
|
||
.tab.active::after{
|
||
content:'';position:absolute;left:14px;right:14px;bottom:-1px;
|
||
height:2px;background:var(--brass);
|
||
}
|
||
.tab .badge{
|
||
font-family:var(--f-mono);font-size:9px;
|
||
background:var(--storm);color:#fff;
|
||
padding:1px 5px;border-radius:1px;margin-left:5px;letter-spacing:0;
|
||
}
|
||
.tab.active .badge{background:var(--brass)}
|
||
|
||
.panel{display:none;animation:fadeIn .25s ease}
|
||
.panel.active{display:block}
|
||
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||
|
||
/* ============ TYPOGRAPHY ============ */
|
||
.section-header{
|
||
display:flex;align-items:baseline;gap:10px;
|
||
margin:24px 0 12px;
|
||
}
|
||
.section-header::before,.section-header::after{
|
||
content:'';flex:1;height:1px;background:var(--rule-soft);
|
||
}
|
||
.section-header h2{
|
||
font-family:var(--f-mono);font-size:10.5px;font-weight:500;
|
||
letter-spacing:.22em;text-transform:uppercase;
|
||
color:var(--sepia);margin:0;flex:0 0 auto;
|
||
}
|
||
.section-header .ornament{color:var(--brass);font-family:var(--f-display);font-style:italic}
|
||
|
||
.display-num{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-feature-settings:"tnum"}
|
||
.display-serif{font-family:var(--f-display);font-style:italic;font-variation-settings:"opsz" 60,"SOFT" 30}
|
||
.label-mono{font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500}
|
||
|
||
/* ============ ALERTS ============ */
|
||
.alert{
|
||
background:var(--storm-soft);
|
||
border-left:3px solid var(--storm);
|
||
padding:11px 14px;margin-bottom:12px;
|
||
display:flex;align-items:flex-start;gap:10px;cursor:pointer;
|
||
border-top:1px solid var(--rule-soft);border-right:1px solid var(--rule-soft);border-bottom:1px solid var(--rule-soft);
|
||
}
|
||
.alert.warn{background:var(--sun-soft);border-left-color:var(--sun)}
|
||
.alert-glyph{width:20px;height:20px;flex-shrink:0;color:var(--storm);margin-top:1px}
|
||
.alert.warn .alert-glyph{color:var(--sun)}
|
||
.alert-text{flex:1;font-size:13px;color:var(--ink-mid)}
|
||
.alert-text strong{display:block;font-family:var(--f-display);font-style:italic;font-weight:600;font-size:14.5px;color:var(--ink-deep);margin-bottom:1px}
|
||
.alert-arrow{color:var(--sepia);font-family:var(--f-mono);font-size:14px}
|
||
|
||
/* ============ GPS CARD ============ */
|
||
.gps-card{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
border:1px solid var(--brass-deep);
|
||
padding:18px 18px 16px;
|
||
margin-bottom:18px;
|
||
position:relative;
|
||
background-image:
|
||
linear-gradient(135deg,rgba(255,255,255,.03) 0%,transparent 50%),
|
||
radial-gradient(ellipse at top right,rgba(200,159,84,.08) 0%,transparent 60%);
|
||
}
|
||
.gps-card::before{
|
||
content:'';position:absolute;inset:5px;border:1px solid rgba(160,120,50,.35);pointer-events:none;
|
||
}
|
||
.gps-card.idle{
|
||
background:var(--bg-paper);color:var(--ink-deep);
|
||
border:1px solid var(--rule);
|
||
background-image:none;
|
||
}
|
||
.gps-card.idle::before{border-color:var(--rule-soft)}
|
||
.gps-head{display:flex;align-items:center;gap:10px;margin-bottom:6px;position:relative}
|
||
.gps-head h3{
|
||
font-family:var(--f-display);font-style:italic;font-weight:500;
|
||
font-variation-settings:"opsz" 24;
|
||
font-size:18px;margin:0;letter-spacing:-.005em;
|
||
}
|
||
.gps-card.idle .gps-head h3{color:var(--ink-deep)}
|
||
.gps-card .gps-head h3{color:var(--brass-bright)}
|
||
.gps-sub{
|
||
font-family:var(--f-mono);font-size:10px;letter-spacing:.16em;
|
||
text-transform:uppercase;color:var(--sepia);
|
||
margin-bottom:14px;position:relative;
|
||
}
|
||
.gps-card:not(.idle) .gps-sub{color:rgba(200,159,84,.7)}
|
||
.live-dot{
|
||
display:inline-block;width:7px;height:7px;border-radius:50%;
|
||
background:#ff4444;margin-right:6px;
|
||
animation:livePulse 1.4s infinite;
|
||
box-shadow:0 0 0 0 rgba(255,68,68,.5);
|
||
}
|
||
@keyframes livePulse{0%,100%{box-shadow:0 0 0 0 rgba(255,68,68,.5)}50%{box-shadow:0 0 0 6px rgba(255,68,68,0)}}
|
||
.gps-stats{
|
||
display:grid;grid-template-columns:repeat(3,1fr);
|
||
gap:12px;margin:14px 0 16px;position:relative;
|
||
}
|
||
.gps-stat{position:relative}
|
||
.gps-stat-label{font-family:var(--f-mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:rgba(200,159,84,.7);margin-bottom:2px;font-weight:500}
|
||
.gps-card.idle .gps-stat-label{color:var(--sepia)}
|
||
.gps-stat-value{
|
||
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
|
||
font-size:24px;font-weight:500;line-height:1.05;
|
||
color:var(--brass-bright);
|
||
}
|
||
.gps-card.idle .gps-stat-value{color:var(--ink-deep)}
|
||
.gps-stat-unit{font-size:11px;font-weight:400;opacity:.6;margin-left:3px}
|
||
.gps-actions{display:flex;gap:8px;margin-top:6px;position:relative}
|
||
.gps-actions .btn{flex:1;justify-content:center}
|
||
|
||
/* ============ STATS GRID ============ */
|
||
.stats{
|
||
display:grid;grid-template-columns:repeat(2,1fr);gap:1px;
|
||
background:var(--rule-soft);
|
||
border:1px solid var(--rule);
|
||
margin-bottom:18px;
|
||
}
|
||
@media(min-width:560px){.stats{grid-template-columns:repeat(4,1fr)}}
|
||
.stat{
|
||
background:var(--bg-paper);
|
||
padding:14px 14px 12px;
|
||
position:relative;
|
||
}
|
||
.stat::before{
|
||
content:'';position:absolute;top:0;left:0;right:0;height:2px;
|
||
background:var(--brass);transform:scaleX(.4);transform-origin:left;
|
||
}
|
||
.stat-label{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--sepia);font-weight:500;margin-bottom:6px}
|
||
.stat-value{
|
||
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
|
||
font-size:22px;font-weight:500;line-height:1;
|
||
color:var(--ink-deep);letter-spacing:-.01em;
|
||
}
|
||
.stat-sub{font-size:11px;color:var(--sepia);margin-top:4px;font-style:italic;font-family:var(--f-display);font-variation-settings:"opsz" 12}
|
||
|
||
/* ============ BUTTONS ============ */
|
||
.btn{
|
||
display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
||
padding:10px 16px;min-height:42px;
|
||
background:var(--bg-paper);color:var(--ink-deep);
|
||
border:1px solid var(--rule);
|
||
font-family:var(--f-mono);font-size:11px;font-weight:500;
|
||
letter-spacing:.14em;text-transform:uppercase;
|
||
cursor:pointer;
|
||
transition:all .15s;
|
||
}
|
||
.btn:hover{background:var(--bg-aged);border-color:var(--sepia)}
|
||
.btn:active{transform:translateY(1px)}
|
||
.btn-primary{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
border-color:var(--ink-deep);
|
||
box-shadow:inset 0 1px 0 var(--ink-mid),0 1px 0 var(--brass);
|
||
}
|
||
.btn-primary:hover{background:var(--ink-mid);border-color:var(--ink-mid);color:var(--bg-paper)}
|
||
.btn-brass{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
|
||
.btn-brass:hover{background:var(--brass-deep);color:var(--bg-paper)}
|
||
.btn-danger{color:var(--storm);border-color:var(--storm)}
|
||
.btn-danger:hover{background:var(--storm-soft);color:var(--storm)}
|
||
.btn-block{width:100%}
|
||
.btn-sm{padding:7px 11px;min-height:32px;font-size:10px}
|
||
.btn-big{padding:14px 18px;min-height:50px;font-size:11.5px}
|
||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
||
|
||
/* ============ ENTRIES ============ */
|
||
.entries{display:flex;flex-direction:column;gap:14px}
|
||
.entry{
|
||
background:var(--bg-paper);
|
||
border:1px solid var(--rule-soft);
|
||
padding:14px 16px 12px;
|
||
position:relative;
|
||
}
|
||
.entry::before{
|
||
content:'';position:absolute;left:0;top:0;bottom:0;width:2px;
|
||
background:var(--ocean);
|
||
}
|
||
.entry.maint::before{background:var(--brass)}
|
||
.entry.pending::before{background:var(--sun)}
|
||
.entry.pending.overdue::before{background:var(--storm)}
|
||
.entry.pending.done{opacity:.6}
|
||
.entry.pending.done::before{background:var(--algae)}
|
||
.entry-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:6px}
|
||
.entry-meta{flex:1;min-width:0}
|
||
.entry-date{font-family:var(--f-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--sepia);margin-bottom:4px;font-weight:500}
|
||
.entry-date .sep{color:var(--rule);margin:0 6px}
|
||
.entry-title{
|
||
font-family:var(--f-display);font-style:italic;font-weight:500;
|
||
font-variation-settings:"opsz" 36,"SOFT" 30;
|
||
font-size:18.5px;line-height:1.2;color:var(--ink-deep);
|
||
letter-spacing:-.005em;
|
||
}
|
||
.entry-actions{display:flex;gap:1px;flex-shrink:0}
|
||
.icon-btn{
|
||
background:none;border:none;cursor:pointer;
|
||
width:30px;height:30px;color:var(--sepia);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-family:var(--f-mono);font-size:12px;
|
||
transition:all .15s;border-radius:1px;
|
||
}
|
||
.icon-btn:hover{background:var(--bg-aged);color:var(--ink-deep)}
|
||
.icon-btn.del:hover{background:var(--storm-soft);color:var(--storm)}
|
||
.icon-btn svg{width:14px;height:14px}
|
||
|
||
.entry-body{margin-top:6px}
|
||
.entry-divider{height:1px;background:var(--rule-fade);margin:8px 0;position:relative}
|
||
.entry-divider::after{
|
||
content:'◆';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||
background:var(--bg-paper);padding:0 8px;color:var(--rule);font-size:6px;
|
||
}
|
||
.entry-grid{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;align-items:baseline;font-size:13px;margin:6px 0}
|
||
.entry-grid dt{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500;padding-top:1px}
|
||
.entry-grid dd{margin:0;color:var(--ink-deep)}
|
||
.entry-grid dd .num{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-weight:500}
|
||
.entry-grid dd .delta{color:var(--sepia);font-family:var(--f-mono);font-size:11px}
|
||
|
||
.entry-passengers{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||
.pax-pill{
|
||
font-family:var(--f-mono);font-size:10px;letter-spacing:.05em;
|
||
background:transparent;color:var(--ink-mid);
|
||
padding:2px 8px;border:1px solid var(--rule);
|
||
border-radius:1px;
|
||
}
|
||
.entry-notes{
|
||
margin-top:8px;padding:8px 12px;
|
||
background:var(--bg-aged);border-left:2px solid var(--rule);
|
||
font-family:var(--f-display);font-style:italic;font-weight:400;
|
||
font-variation-settings:"opsz" 14,"SOFT" 60;
|
||
font-size:14px;color:var(--ink-mid);line-height:1.55;
|
||
white-space:pre-wrap;
|
||
}
|
||
.entry-track{
|
||
margin-top:8px;padding:8px 12px;
|
||
background:var(--bg-canvas);border:1px dashed var(--brass);
|
||
display:flex;justify-content:space-between;align-items:center;gap:10px;
|
||
flex-wrap:wrap;
|
||
}
|
||
.entry-track-stats{display:flex;gap:14px;font-family:var(--f-mono);font-size:11px;color:var(--ink-deep)}
|
||
.entry-track-stats span{color:var(--sepia);font-size:9.5px;letter-spacing:.15em;text-transform:uppercase;display:block;margin-bottom:1px}
|
||
.entry-track-stats strong{font-weight:500;font-variant-numeric:tabular-nums}
|
||
.btn-track{padding:6px 10px;font-size:10px;min-height:28px;background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
|
||
.btn-track:hover{background:var(--brass-deep);color:var(--bg-paper)}
|
||
|
||
.status-pill{
|
||
display:inline-block;padding:1px 7px;
|
||
font-family:var(--f-mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;
|
||
font-weight:500;border:1px solid;border-radius:1px;
|
||
}
|
||
.status-overdue{color:var(--storm);border-color:var(--storm);background:var(--storm-soft)}
|
||
.status-soon{color:var(--sun);border-color:var(--sun);background:var(--sun-soft)}
|
||
.status-ok{color:var(--algae);border-color:var(--algae);background:var(--algae-soft)}
|
||
.status-done{color:var(--ink-mid);border-color:var(--rule);background:var(--bg-aged)}
|
||
|
||
/* ============ MEDIA ============ */
|
||
.media-grid{
|
||
display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));
|
||
gap:5px;margin-top:8px;
|
||
}
|
||
.media-thumb{
|
||
aspect-ratio:1;background:var(--bg-aged);
|
||
border:1px solid var(--rule-soft);
|
||
cursor:pointer;position:relative;overflow:hidden;
|
||
display:flex;align-items:center;justify-content:center;
|
||
}
|
||
.media-thumb img,.media-thumb video{width:100%;height:100%;object-fit:cover}
|
||
.media-thumb.audio{background:var(--ink-deep);color:var(--brass-bright)}
|
||
.media-thumb.audio svg{width:22px;height:22px}
|
||
.media-thumb.video::after{content:'';position:absolute;inset:0;background:rgba(14,42,61,.3) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23faf2dd'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E") center/22px no-repeat}
|
||
|
||
/* ============ EMPTY ============ */
|
||
.empty{
|
||
text-align:center;padding:48px 24px;
|
||
background:var(--bg-paper);
|
||
border:1px solid var(--rule-soft);
|
||
position:relative;
|
||
}
|
||
.empty::before,.empty::after{
|
||
content:'';position:absolute;left:50%;width:120px;height:1px;
|
||
background:var(--rule-soft);transform:translateX(-50%);
|
||
}
|
||
.empty::before{top:18px}
|
||
.empty::after{bottom:18px}
|
||
.empty-rose{color:var(--rule);margin:0 auto 14px;width:48px;height:48px;opacity:.6}
|
||
.empty-title{font-family:var(--f-display);font-style:italic;font-weight:500;font-variation-settings:"opsz" 24;font-size:19px;color:var(--ink-deep);margin-bottom:4px}
|
||
.empty-text{font-size:13.5px;color:var(--sepia);max-width:300px;margin:0 auto;line-height:1.55}
|
||
|
||
/* ============ MODALS ============ */
|
||
.modal-backdrop{
|
||
position:fixed;inset:0;background:rgba(14,42,61,.55);
|
||
display:none;align-items:flex-end;justify-content:center;
|
||
z-index:100;overflow-y:auto;
|
||
backdrop-filter:blur(3px);
|
||
}
|
||
.modal-backdrop.show{display:flex}
|
||
.modal{
|
||
background:var(--bg-paper);
|
||
width:100%;max-width:560px;
|
||
border-top:3px solid var(--brass);
|
||
margin-top:auto;max-height:95vh;
|
||
display:flex;flex-direction:column;
|
||
animation:slideUp .25s cubic-bezier(.2,.8,.2,1);
|
||
border-left:1px solid var(--rule);border-right:1px solid var(--rule);
|
||
background-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.3 0 0 0 0 0.15 0 0 0 0.1 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
|
||
}
|
||
@keyframes slideUp{from{transform:translateY(50px);opacity:0}}
|
||
@media(min-width:600px){
|
||
.modal-backdrop{align-items:center;padding:20px}
|
||
.modal{margin:0;max-height:90vh;border:1px solid var(--rule);border-top:3px solid var(--brass)}
|
||
}
|
||
.modal-head{
|
||
padding:14px 18px;
|
||
border-bottom:1px solid var(--rule-soft);
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
flex-shrink:0;background:var(--bg-paper);
|
||
}
|
||
.modal-head h3{
|
||
font-family:var(--f-display);font-style:italic;font-weight:500;
|
||
font-variation-settings:"opsz" 30;font-size:19px;
|
||
margin:0;color:var(--ink-deep);
|
||
}
|
||
.modal-body{padding:18px;overflow-y:auto;flex:1}
|
||
.modal-foot{
|
||
padding:14px 18px;
|
||
border-top:1px solid var(--rule-soft);
|
||
display:flex;justify-content:flex-end;gap:8px;flex-shrink:0;
|
||
padding-bottom:max(14px,env(safe-area-inset-bottom));
|
||
background:var(--bg-paper);
|
||
}
|
||
|
||
.field{margin-bottom:14px}
|
||
.field label{display:block;margin-bottom:5px}
|
||
.field-label{font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500}
|
||
.field input,.field textarea,.field select{
|
||
width:100%;padding:10px 12px;
|
||
background:var(--bg-canvas);
|
||
border:1px solid var(--rule);
|
||
font-family:var(--f-body);font-size:15px;
|
||
color:var(--ink-deep);
|
||
-webkit-appearance:none;border-radius:0;
|
||
transition:border-color .15s,background .15s;
|
||
}
|
||
.field input[type="number"],.field input[type="date"]{font-family:var(--f-mono);font-size:14px}
|
||
.field input:focus,.field textarea:focus,.field select:focus{
|
||
outline:none;border-color:var(--brass);background:var(--bg-paper);
|
||
box-shadow:0 0 0 3px rgba(160,120,50,.12);
|
||
}
|
||
.field textarea{resize:vertical;min-height:80px;font-family:var(--f-display);font-style:italic;font-variation-settings:"opsz" 14;font-size:15px}
|
||
.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)}
|
||
|
||
.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}
|
||
.pax-tag{
|
||
background:transparent;color:var(--ink-mid);
|
||
padding:3px 6px 3px 10px;border:1px solid var(--rule);
|
||
font-family:var(--f-mono);font-size:11px;
|
||
display:inline-flex;align-items:center;gap:5px;
|
||
}
|
||
.pax-tag button{background:none;border:none;color:var(--sepia);cursor:pointer;font-size:14px;padding:0;line-height:1}
|
||
|
||
.media-toolbar{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:8px}
|
||
.media-btn{
|
||
background:var(--bg-canvas);color:var(--ink-deep);
|
||
border:1px solid var(--rule);
|
||
padding:12px 6px;font-family:var(--f-mono);
|
||
font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;
|
||
display:flex;flex-direction:column;align-items:center;gap:5px;cursor:pointer;
|
||
transition:all .15s;
|
||
}
|
||
.media-btn:hover{background:var(--bg-aged);border-color:var(--sepia)}
|
||
.media-btn:active{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
|
||
.media-btn svg{width:18px;height:18px;color:var(--brass)}
|
||
.media-btn:active svg{color:var(--bg-paper)}
|
||
.modal-media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(74px,1fr));gap:5px;margin-top:8px}
|
||
.media-item{aspect-ratio:1;background:var(--bg-aged);border:1px solid var(--rule-soft);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
|
||
.media-item img,.media-item video{width:100%;height:100%;object-fit:cover}
|
||
.media-item.audio{background:var(--ink-deep);color:var(--brass-bright)}
|
||
.media-item.audio svg{width:24px;height:24px}
|
||
.media-item.video::after{content:'';position:absolute;inset:0;background:rgba(14,42,61,.3) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23faf2dd'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E") center/24px no-repeat}
|
||
.media-remove{position:absolute;top:2px;right:2px;background:var(--ink-deep);color:var(--bg-paper);border:none;width:20px;height:20px;font-size:12px;cursor:pointer;line-height:1;padding:0;font-family:var(--f-mono)}
|
||
|
||
/* ============ RECORDER ============ */
|
||
.recorder-modal{max-width:340px}
|
||
.recorder-body{padding:32px 24px;text-align:center}
|
||
.rec-circle{
|
||
width:104px;height:104px;border-radius:50%;
|
||
background:var(--storm);color:var(--bg-paper);
|
||
display:flex;align-items:center;justify-content:center;
|
||
margin:0 auto 18px;border:none;cursor:pointer;
|
||
position:relative;
|
||
box-shadow:0 0 0 4px var(--bg-paper),0 0 0 5px var(--storm);
|
||
}
|
||
.rec-circle svg{width:38px;height:38px}
|
||
.rec-circle.recording{background:var(--ink-deep);box-shadow:0 0 0 4px var(--bg-paper),0 0 0 5px var(--ink-deep)}
|
||
.rec-circle.recording::after{content:'';position:absolute;inset:-12px;border-radius:50%;border:2px solid var(--storm);animation:recPulse 1.2s infinite}
|
||
@keyframes recPulse{0%{opacity:1;transform:scale(.95)}100%{opacity:0;transform:scale(1.4)}}
|
||
.rec-time{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-size:32px;font-weight:500;color:var(--ink-deep);margin-bottom:4px}
|
||
.rec-status{font-family:var(--f-display);font-style:italic;font-size:14px;color:var(--sepia);margin-bottom:18px;min-height:22px}
|
||
|
||
/* ============ TRACKING SCREEN ============ */
|
||
.tracking-modal{max-width:100%;width:100%;height:100vh;max-height:100vh;border-radius:0;border:none;border-top:none}
|
||
.tracking-head{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
padding:max(14px,env(safe-area-inset-top)) 18px 12px;
|
||
border-bottom:1px solid var(--brass-deep);
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
}
|
||
.tracking-head h3{
|
||
font-family:var(--f-display);font-style:italic;font-weight:500;
|
||
font-variation-settings:"opsz" 24;font-size:18px;margin:0;
|
||
color:var(--brass-bright);display:flex;align-items:center;gap:8px;
|
||
}
|
||
.tracking-stats{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
padding:14px 18px 18px;
|
||
display:grid;grid-template-columns:repeat(2,1fr);gap:14px 18px;
|
||
border-bottom:1px solid var(--brass-deep);
|
||
position:relative;
|
||
}
|
||
.tracking-stats::after{
|
||
content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);
|
||
width:60px;height:3px;background:var(--brass);
|
||
}
|
||
.t-stat-label{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;color:rgba(200,159,84,.7);font-weight:500;margin-bottom:2px}
|
||
.t-stat-value{
|
||
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
|
||
font-size:30px;font-weight:500;line-height:1;color:var(--brass-bright);
|
||
}
|
||
.t-stat-unit{font-size:13px;font-weight:400;color:rgba(200,159,84,.5);margin-left:3px}
|
||
.tracking-map{flex:1;min-height:240px;background:var(--bg-aged);border-bottom:1px solid var(--rule)}
|
||
.tracking-foot{
|
||
padding:14px 18px;display:flex;gap:8px;background:var(--bg-paper);
|
||
padding-bottom:max(14px,env(safe-area-inset-bottom));
|
||
}
|
||
.tracking-foot .btn{flex:1}
|
||
|
||
#map-modal-content{height:55vh;min-height:280px;background:var(--bg-aged);border:1px solid var(--rule)}
|
||
.map-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px;padding:12px;background:var(--bg-canvas);border:1px solid var(--rule-soft)}
|
||
.map-stat{text-align:center}
|
||
.map-stat-num{display:block;font-family:var(--f-mono);font-size:18px;font-weight:500;color:var(--brass-deep);font-variant-numeric:tabular-nums}
|
||
.map-stat-lbl{font-family:var(--f-mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);margin-top:2px;font-weight:500}
|
||
|
||
/* ============ VIEWER ============ */
|
||
.viewer-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.92);display:none;align-items:center;justify-content:center;z-index:200;padding:20px}
|
||
.viewer-backdrop.show{display:flex;flex-direction:column}
|
||
.viewer-content{max-width:100%;max-height:80vh}
|
||
.viewer-content img,.viewer-content video{max-width:100%;max-height:80vh;object-fit:contain}
|
||
.viewer-content audio{width:90vw;max-width:400px}
|
||
.viewer-close{position:absolute;top:14px;right:14px;background:rgba(255,255,255,.1);color:#fff;border:1px solid rgba(255,255,255,.25);width:40px;height:40px;font-size:18px;cursor:pointer;font-family:var(--f-mono)}
|
||
.viewer-actions{margin-top:18px;display:flex;gap:8px}
|
||
.viewer-actions .btn{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.25)}
|
||
.viewer-actions .btn:hover{background:rgba(255,255,255,.18);color:#fff}
|
||
|
||
/* ============ TOAST + FAB ============ */
|
||
.toast{
|
||
position:fixed;bottom:90px;left:50%;transform:translateX(-50%);
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
padding:11px 18px;
|
||
font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:500;
|
||
z-index:300;opacity:0;transition:opacity .2s;pointer-events:none;
|
||
border:1px solid var(--brass);max-width:90%;
|
||
}
|
||
.toast.show{opacity:1}
|
||
.fab{
|
||
position:fixed;bottom:max(20px,env(safe-area-inset-bottom));right:18px;
|
||
background:var(--brass);color:var(--bg-paper);
|
||
border:1px solid var(--brass-deep);
|
||
border-radius:50%;width:58px;height:58px;
|
||
cursor:pointer;z-index:50;
|
||
box-shadow:0 4px 14px rgba(14,42,61,.35),inset 0 1px 0 var(--brass-bright);
|
||
display:flex;align-items:center;justify-content:center;
|
||
}
|
||
.fab:active{transform:translateY(1px)}
|
||
.fab svg{width:22px;height:22px}
|
||
|
||
/* ============ PENDING TOGGLE ============ */
|
||
.pending-toggle{display:flex;gap:0;margin-bottom:14px;border:1px solid var(--rule)}
|
||
.pending-toggle button{
|
||
flex:1;padding:9px;background:var(--bg-paper);border:none;
|
||
font-family:var(--f-mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;
|
||
cursor:pointer;color:var(--sepia);
|
||
border-right:1px solid var(--rule);
|
||
}
|
||
.pending-toggle button:last-child{border-right:none}
|
||
.pending-toggle button.active{background:var(--ink-deep);color:var(--bg-paper)}
|
||
|
||
.priority-row{display:flex;gap:0;border:1px solid var(--rule)}
|
||
.priority-row label{flex:1;text-align:center;padding:9px;border:none;font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;cursor:pointer;background:var(--bg-paper);color:var(--sepia);border-right:1px solid var(--rule);text-transform:uppercase;font-weight:500;margin:0}
|
||
.priority-row label:last-child{border-right:none}
|
||
.priority-row input{display:none}
|
||
.priority-row label:has(input:checked){background:var(--ink-deep);color:var(--brass-bright)}
|
||
|
||
/* ============ EXPORT PANEL ============ */
|
||
.export-card{background:var(--bg-paper);border:1px solid var(--rule);padding:14px 16px;margin-bottom:14px}
|
||
.export-card-title{font-family:var(--f-display);font-style:italic;font-weight:500;font-size:17px;margin-bottom:4px;color:var(--ink-deep)}
|
||
.export-card-text{font-size:13.5px;color:var(--sepia);line-height:1.55;font-family:var(--f-display);font-style:italic;font-variation-settings:"opsz" 14}
|
||
.export-actions{display:flex;flex-direction:column;gap:7px}
|
||
|
||
/* ============ ANCHOR WATCH ============ */
|
||
.anchor-card{
|
||
background:var(--bg-paper);color:var(--ink-deep);
|
||
border:1px solid var(--rule);
|
||
padding:18px 18px 16px;margin-bottom:18px;
|
||
position:relative;
|
||
}
|
||
.anchor-card::before{content:'';position:absolute;inset:5px;border:1px solid var(--rule-soft);pointer-events:none}
|
||
.anchor-card.active{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
border-color:var(--brass-deep);
|
||
background-image:radial-gradient(ellipse at top right,rgba(63,119,104,.12) 0%,transparent 60%);
|
||
}
|
||
.anchor-card.active::before{border-color:rgba(160,120,50,.35)}
|
||
.anchor-card.warn{background:#3d2818;color:var(--bg-paper);border-color:var(--sun)}
|
||
.anchor-card.warn::before{border-color:rgba(182,112,37,.4)}
|
||
.anchor-status{font-family:var(--f-mono);font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;font-weight:500}
|
||
.anchor-status.safe{color:var(--algae)}
|
||
.anchor-card.active .anchor-status.safe{color:#7eb695}
|
||
.anchor-status.warn{color:var(--sun)}
|
||
.anchor-card.warn .anchor-status.warn{color:var(--sun)}
|
||
|
||
/* ============ ALARM SCREEN ============ */
|
||
.alarm-backdrop{
|
||
position:fixed;inset:0;
|
||
background:radial-gradient(circle at center,#5a1818 0%,#2a0808 100%);
|
||
display:none;align-items:center;justify-content:center;z-index:500;
|
||
padding:20px;flex-direction:column;
|
||
animation:alarmFlash 1s infinite alternate;
|
||
}
|
||
@keyframes alarmFlash{from{background:radial-gradient(circle at center,#5a1818 0%,#2a0808 100%)}to{background:radial-gradient(circle at center,#8c2828 0%,#4a1414 100%)}}
|
||
.alarm-backdrop.show{display:flex}
|
||
.alarm-icon{
|
||
width:80px;height:80px;color:#fff;margin-bottom:14px;
|
||
animation:alarmShake .15s infinite alternate;
|
||
}
|
||
@keyframes alarmShake{from{transform:rotate(-3deg) scale(1)}to{transform:rotate(3deg) scale(1.05)}}
|
||
.alarm-title{
|
||
font-family:var(--f-display);font-style:italic;font-weight:700;
|
||
font-variation-settings:"opsz" 96,"SOFT" 0;
|
||
font-size:36px;color:#fff;text-align:center;line-height:1;
|
||
margin-bottom:6px;letter-spacing:-.02em;
|
||
text-shadow:0 0 24px rgba(255,255,255,.3);
|
||
}
|
||
.alarm-sub{
|
||
font-family:var(--f-mono);font-size:11px;letter-spacing:.22em;
|
||
text-transform:uppercase;color:rgba(255,255,255,.85);
|
||
margin-bottom:18px;text-align:center;
|
||
}
|
||
.alarm-distance{
|
||
font-family:var(--f-mono);font-variant-numeric:tabular-nums;
|
||
font-size:54px;font-weight:600;color:#fff;line-height:1;
|
||
margin-bottom:4px;
|
||
}
|
||
.alarm-distance-lbl{font-family:var(--f-mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:rgba(255,255,255,.7);margin-bottom:24px}
|
||
.alarm-actions{
|
||
display:flex;flex-direction:column;gap:8px;
|
||
width:100%;max-width:340px;margin-bottom:14px;
|
||
}
|
||
.alarm-btn{
|
||
display:flex;align-items:center;justify-content:center;gap:8px;
|
||
padding:14px;background:rgba(255,255,255,.95);color:var(--storm);
|
||
border:none;cursor:pointer;
|
||
font-family:var(--f-mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-weight:600;
|
||
}
|
||
.alarm-btn svg{width:18px;height:18px}
|
||
.alarm-btn.wa{background:#25d366;color:#fff}
|
||
.alarm-btn.sms{background:#1f5b76;color:#fff}
|
||
.alarm-btn.mail{background:var(--brass);color:#fff}
|
||
.alarm-btn:active{transform:scale(.98)}
|
||
.alarm-dismiss{
|
||
margin-top:8px;background:transparent;border:1px solid rgba(255,255,255,.4);
|
||
color:rgba(255,255,255,.85);padding:11px 22px;cursor:pointer;
|
||
font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;
|
||
}
|
||
.alarm-dismiss:active{background:rgba(255,255,255,.1)}
|
||
|
||
/* ============ ANCHOR WATCH FULLSCREEN ============ */
|
||
.anchor-modal{max-width:100%;width:100%;height:100vh;max-height:100vh;border-radius:0;border:none;border-top:none}
|
||
.anchor-head{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
padding:max(14px,env(safe-area-inset-top)) 18px 12px;
|
||
border-bottom:1px solid var(--brass-deep);
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
}
|
||
.anchor-head h3{
|
||
font-family:var(--f-display);font-style:italic;font-weight:500;
|
||
font-variation-settings:"opsz" 24;font-size:18px;margin:0;
|
||
color:var(--brass-bright);
|
||
}
|
||
.anchor-stats-bar{
|
||
background:var(--ink-deep);color:var(--bg-paper);
|
||
padding:14px 18px 18px;
|
||
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
|
||
border-bottom:1px solid var(--brass-deep);position:relative;
|
||
}
|
||
.anchor-stats-bar::after{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:60px;height:3px;background:var(--brass)}
|
||
.anchor-stat{text-align:left}
|
||
.anchor-stat-lbl{font-family:var(--f-mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:rgba(200,159,84,.7);margin-bottom:2px;font-weight:500}
|
||
.anchor-stat-val{font-family:var(--f-mono);font-variant-numeric:tabular-nums;font-size:24px;font-weight:500;line-height:1;color:var(--brass-bright)}
|
||
.anchor-stat-val.warn{color:var(--sun)}
|
||
.anchor-stat-val.alarm{color:#ff5555}
|
||
.anchor-stat-unit{font-size:11px;color:rgba(200,159,84,.5);margin-left:3px}
|
||
.anchor-map{flex:1;min-height:240px;background:var(--bg-aged)}
|
||
.anchor-foot{
|
||
padding:14px 18px;display:flex;gap:8px;background:var(--bg-paper);
|
||
padding-bottom:max(14px,env(safe-area-inset-bottom));flex-direction:column;
|
||
}
|
||
.anchor-foot-row{display:flex;gap:8px}
|
||
.anchor-foot-row .btn{flex:1}
|
||
.anchor-radius-row{display:flex;align-items:center;gap:10px;padding:8px 0}
|
||
.anchor-radius-row label{font-family:var(--f-mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--sepia);font-weight:500;flex-shrink:0}
|
||
.anchor-radius-row input[type=range]{flex:1;-webkit-appearance:none;height:4px;background:var(--rule-soft);border:none;outline:none}
|
||
.anchor-radius-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--brass);border:2px solid var(--bg-paper);box-shadow:0 1px 4px rgba(0,0,0,.2);cursor:pointer}
|
||
.anchor-radius-row input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--brass);border:2px solid var(--bg-paper);cursor:pointer}
|
||
.anchor-radius-val{font-family:var(--f-mono);font-size:13px;font-weight:500;color:var(--ink-deep);min-width:50px;text-align:right;font-variant-numeric:tabular-nums}
|
||
|
||
/* ============ CONTACTS ============ */
|
||
.contact-card{
|
||
background:var(--bg-paper);border:1px solid var(--rule-soft);
|
||
padding:12px 14px;margin-bottom:8px;
|
||
display:flex;justify-content:space-between;align-items:flex-start;gap:10px;
|
||
}
|
||
.contact-info{flex:1;min-width:0}
|
||
.contact-name{font-family:var(--f-display);font-style:italic;font-weight:500;font-size:16px;color:var(--ink-deep);margin-bottom:2px}
|
||
.contact-meta{font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);letter-spacing:.04em;line-height:1.5}
|
||
.contact-channels{display:flex;gap:5px;margin-top:5px}
|
||
.channel-pill{font-family:var(--f-mono);font-size:9px;letter-spacing:.15em;text-transform:uppercase;padding:2px 7px;border:1px solid var(--rule);background:var(--bg-canvas);color:var(--ink-mid);font-weight:500}
|
||
.channel-pill.on{background:var(--brass);color:var(--bg-paper);border-color:var(--brass-deep)}
|
||
|
||
/* ============ INSTALL BANNER ============ */
|
||
.install-tip{font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);padding:8px 0;line-height:1.55}
|
||
|
||
/* ============ ZONES ============ */
|
||
.zone-entry{
|
||
background:var(--bg-paper);border:1px solid var(--rule-soft);
|
||
padding:12px 14px;margin-bottom:8px;cursor:pointer;
|
||
position:relative;border-left:3px solid;
|
||
}
|
||
.zone-entry.forbidden{border-left-color:var(--storm)}
|
||
.zone-entry.attention{border-left-color:var(--sun)}
|
||
.zone-entry-name{font-family:var(--f-display);font-style:italic;font-weight:500;font-size:16px;color:var(--ink-deep);margin-bottom:2px}
|
||
.zone-entry-meta{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;color:var(--sepia)}
|
||
.zone-toast{
|
||
position:fixed;top:88px;left:50%;transform:translateX(-50%);
|
||
background:var(--storm);color:#fff;padding:10px 16px;
|
||
font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
|
||
z-index:90;border:1px solid rgba(255,255,255,.2);max-width:90%;
|
||
display:none;animation:slideDown .25s ease;
|
||
}
|
||
.zone-toast.warn{background:var(--sun)}
|
||
@keyframes slideDown{from{transform:translate(-50%,-20px);opacity:0}}
|
||
.zone-toast.show{display:block}
|
||
|
||
@media print{
|
||
header,.tabs,.toolbar,.fab,.entry-actions,.alert,.gps-card,.anchor-card{display:none!important}
|
||
.panel{display:block!important;page-break-inside:avoid}
|
||
body{background:#fff;background-image:none}
|
||
.entry{break-inside:avoid;border:1px solid #999}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="header-row">
|
||
<svg class="compass-mark" viewBox="0 0 100 100" aria-hidden="true">
|
||
<g fill="none" stroke="currentColor" stroke-width="1">
|
||
<circle cx="50" cy="50" r="46"/>
|
||
<circle cx="50" cy="50" r="38"/>
|
||
<circle cx="50" cy="50" r="3" fill="currentColor"/>
|
||
</g>
|
||
<g fill="currentColor">
|
||
<path d="M50 4 L54 50 L50 50 Z"/>
|
||
<path d="M50 4 L46 50 L50 50 Z" opacity=".55"/>
|
||
<path d="M50 96 L46 50 L50 50 Z"/>
|
||
<path d="M50 96 L54 50 L50 50 Z" opacity=".55"/>
|
||
<path d="M96 50 L50 46 L50 50 Z" opacity=".7"/>
|
||
<path d="M96 50 L50 54 L50 50 Z" opacity=".4"/>
|
||
<path d="M4 50 L50 54 L50 50 Z" opacity=".7"/>
|
||
<path d="M4 50 L50 46 L50 50 Z" opacity=".4"/>
|
||
</g>
|
||
<g fill="currentColor" opacity=".5">
|
||
<path d="M82 18 L52 48 L50 50 Z"/>
|
||
<path d="M18 82 L48 52 L50 50 Z"/>
|
||
<path d="M18 18 L48 48 L50 50 Z"/>
|
||
<path d="M82 82 L52 52 L50 50 Z"/>
|
||
</g>
|
||
<text x="50" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="6" fill="currentColor" font-weight="600">N</text>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="container">
|
||
|
||
<div class="tabs" role="tablist">
|
||
<button class="tab active" data-panel="overview">Sumário</button>
|
||
<button class="tab" data-panel="trips">Travessias</button>
|
||
<button class="tab" data-panel="maintenance">Reparos</button>
|
||
<button class="tab" data-panel="pending">Pendências <span class="badge" id="pending-badge" style="display:none">0</span></button>
|
||
<button class="tab" data-panel="zones">Zonas</button>
|
||
<button class="tab" data-panel="export">Arquivo</button>
|
||
</div>
|
||
|
||
<div class="panel active" id="panel-overview">
|
||
<div id="alerts-area"></div>
|
||
<div id="battery-indicator" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-align:right;margin-bottom:4px;cursor:pointer" onclick="cycleBatteryMode()"></div>
|
||
<div id="weather-widget"></div>
|
||
<div id="gps-banner"></div>
|
||
<div id="anchor-banner"></div>
|
||
<div class="stats">
|
||
<div class="stat"><div class="stat-label">Horímetro</div><div class="stat-value" id="stat-hours">0.0</div><div class="stat-sub" id="stat-hours-sub">— sem leituras</div></div>
|
||
<div class="stat"><div class="stat-label">Travessias</div><div class="stat-value" id="stat-trips">0</div><div class="stat-sub" id="stat-trips-sub">registradas</div></div>
|
||
<div class="stat"><div class="stat-label">Reparos</div><div class="stat-value" id="stat-maint">0</div><div class="stat-sub" id="stat-maint-sub">no diário</div></div>
|
||
<div class="stat"><div class="stat-label">Pendentes</div><div class="stat-value" id="stat-pending">0</div><div class="stat-sub" id="stat-pending-sub">a fazer</div></div>
|
||
</div>
|
||
<div id="recent-entries"></div>
|
||
<div style="margin-top:18px;text-align:center"><button class="btn btn-sm" onclick="openChecklistsModal()" style="background:transparent;border:none;color:var(--brass);font-family:var(--f-mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">📋 Checklists de Bordo</button></div>
|
||
</div>
|
||
|
||
<div class="panel" id="panel-trips">
|
||
<div class="toolbar" style="display:grid;grid-template-columns:2fr 1fr;gap:8px"><button class="btn btn-primary btn-big" onclick="openTripModal()">⊕ Nova Travessia</button><label class="btn btn-big" style="cursor:pointer">↑ GPX<input type="file" accept=".gpx,application/gpx+xml" onchange="importGPX(event)" style="display:none"></label></div>
|
||
<div class="entries" id="trips-list"></div>
|
||
</div>
|
||
|
||
<div class="panel" id="panel-maintenance">
|
||
<div class="toolbar"><button class="btn btn-primary btn-block btn-big" onclick="openMaintModal()">⊕ Registrar Reparo</button></div>
|
||
<div class="entries" id="maint-list"></div>
|
||
</div>
|
||
|
||
<div class="panel" id="panel-pending">
|
||
<div class="toolbar"><button class="btn btn-primary btn-block btn-big" onclick="openPendingModal()">⊕ Adicionar Pendência</button></div>
|
||
<div class="pending-toggle">
|
||
<button class="active" data-filter="active" onclick="filterPending('active')">Ativas</button>
|
||
<button data-filter="all" onclick="filterPending('all')">Todas</button>
|
||
</div>
|
||
<div class="entries" id="pending-list"></div>
|
||
</div>
|
||
|
||
<div class="panel" id="panel-zones">
|
||
<div class="toolbar"><button class="btn btn-primary btn-block btn-big" onclick="openZoneEditor()">⊕ Nova Zona</button></div>
|
||
<div style="font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);line-height:1.55;padding:10px 12px;background:var(--bg-paper);border:1px dashed var(--rule);margin-bottom:14px">
|
||
<strong style="color:var(--ink-deep);font-style:normal;font-family:var(--f-mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase">Geofencing · vigia de áreas</strong><br>
|
||
Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção ativa durante rastreio e fundeio.
|
||
</div>
|
||
<div class="entries" id="zones-list"></div>
|
||
</div>
|
||
|
||
<div class="panel" id="panel-export">
|
||
<div class="export-card">
|
||
<div class="export-card-title">Arquivamento & Compartilhamento</div>
|
||
<div class="export-card-text">O arquivo completo inclui mídias (fotos, áudios, vídeos). O CSV exporta apenas os dados para planilhas.</div>
|
||
</div>
|
||
<div class="export-actions">
|
||
<button class="btn btn-block btn-big" onclick="exportJSON(true)">Backup completo · com mídia</button>
|
||
<button class="btn btn-block" onclick="exportJSON(false)">Backup leve · só dados</button>
|
||
<button class="btn btn-block" onclick="exportCSV('trips')">CSV · Travessias</button>
|
||
<button class="btn btn-block" onclick="exportCSV('maint')">CSV · Reparos</button>
|
||
<button class="btn btn-block" onclick="exportCSV('pending')">CSV · Pendências</button>
|
||
<button class="btn btn-block btn-brass" onclick="shareData()">Compartilhar diário</button>
|
||
<button class="btn btn-block" onclick="window.print()">Imprimir / PDF</button>
|
||
<div style="height:1px;background:var(--rule-soft);margin:6px 0"></div>
|
||
<label class="btn btn-block" style="cursor:pointer">Importar backup<input type="file" accept=".json" onchange="importJSON(event)" style="display:none"></label>
|
||
<button class="btn btn-block btn-danger" onclick="resetAll()">Apagar todo o diário</button>
|
||
</div>
|
||
<div class="export-card" style="margin-top:16px"><div class="export-card-title">Espaço a bordo</div><div id="storage-info" style="margin-top:6px;font-family:var(--f-mono);font-size:11.5px;color:var(--ink-mid);letter-spacing:.05em">calculando...</div></div>
|
||
<div class="export-card"><div class="export-card-title">Instalar como aplicativo</div><div class="export-card-text">No Chrome (Android): toque no menu (⋮) → "Adicionar à tela inicial". O diário abrirá em tela cheia, como um aplicativo nativo.</div></div>
|
||
<div class="export-card" style="border-color:var(--brass);background:linear-gradient(180deg,var(--bg-paper),var(--bg-canvas))">
|
||
<div class="export-card-title">☁️ Sincronização na nuvem</div>
|
||
<div class="export-card-text" style="margin-bottom:10px" id="cloud-status">Não conectado · use seu próprio servidor (Coolify/VPS) para sync automático e alarmes remotos.</div>
|
||
<div class="field"><label class="field-label">Servidor</label><input type="url" id="cloud-url" placeholder="https://shivao.seu-dominio.com" style="font-family:var(--f-mono);font-size:13px"></div>
|
||
<div class="field"><label class="field-label">Token de acesso</label><input type="password" id="cloud-token" placeholder="cole o BOAT_TOKEN do servidor" style="font-family:var(--f-mono);font-size:12px"></div>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
<button class="btn btn-block btn-primary" onclick="testCloudConnection()">Testar conexão</button>
|
||
<button class="btn btn-block btn-brass" onclick="cloudPushAll()">↑ Enviar tudo para nuvem</button>
|
||
<button class="btn btn-block" onclick="cloudPullAll()">↓ Baixar da nuvem</button>
|
||
<button class="btn btn-block" onclick="cloudTestAlarm()">Disparar mensagem de teste</button>
|
||
<button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button>
|
||
</div>
|
||
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
|
||
</div>
|
||
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
|
||
<div class="export-card-title">📡 Compartilhar posição em tempo real</div>
|
||
<div class="export-card-text" style="margin-bottom:10px">Crie um link público temporário. Sua tripulação vê a posição do Shivao no mapa, sem precisar de login. Requer servidor na nuvem.</div>
|
||
<div id="share-active-list" style="margin-bottom:10px"></div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Duração</label><select id="share-duration"><option value="60">1 hora</option><option value="180">3 horas</option><option value="360" selected>6 horas</option><option value="720">12 horas</option><option value="1440">24 horas</option><option value="4320">3 dias</option></select></div>
|
||
<div class="field" style="display:flex;align-items:flex-end"><button class="btn btn-block btn-primary" onclick="createShare()">Criar link</button></div>
|
||
</div>
|
||
<div id="share-result" style="font-family:var(--f-mono);font-size:11px;color:var(--ink-deep);margin-top:6px;letter-spacing:.04em;line-height:1.55"></div>
|
||
</div>
|
||
<div class="export-card" style="border-color:var(--ocean);background:linear-gradient(180deg,var(--bg-paper),#dde6ec)">
|
||
<div class="export-card-title">🌬 Meteorologia · Windy Point Forecast</div>
|
||
<div class="export-card-text" style="margin-bottom:10px">Cole sua chave da <a href="https://api.windy.com/" target="_blank" style="color:var(--ocean);font-style:normal;text-decoration:underline">Windy API</a> para usar dados premium (vento u/v, ondas, modelos GFS/ECMWF). Sem chave, usa Open-Meteo grátis.</div>
|
||
<div class="field"><label class="field-label">Chave da API Windy</label><input type="password" id="windy-key" placeholder="cole sua chave aqui" style="font-family:var(--f-mono);font-size:11px"></div>
|
||
<div class="field"><label class="field-label">Modelo preferido</label>
|
||
<select id="windy-model"><option value="gfs">GFS · global, todos parâmetros</option><option value="iconEu">ICON-EU · Europa, alta resolução</option><option value="nam">NAM · América do Norte</option><option value="namConus">NAM-CONUS · EUA continental</option><option value="namHawaii">NAM-Hawaii</option><option value="namAlaska">NAM-Alaska</option><option value="arome">AROME · França</option><option value="canHrdps">HRDPS · Canadá</option></select>
|
||
<div class="field-hint">GFS é o universal. Ondas usam sempre GFS Wave em paralelo.</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
<button class="btn btn-block btn-primary" onclick="saveWeatherKey()">Salvar</button>
|
||
<button class="btn btn-block" onclick="testWindyKey()">Testar chave</button>
|
||
</div>
|
||
<div id="windy-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Modal Travessia -->
|
||
<div class="modal-backdrop" id="trip-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3 id="trip-modal-title">Nova Travessia</h3><button class="icon-btn" onclick="closeModal('trip-modal')">✕</button></div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="trip-id"><input type="hidden" id="trip-track-data">
|
||
<div class="field"><label class="field-label">Destino & Rota</label><input id="trip-destination" placeholder="ex: Marina → Ilha Grande"></div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Saída</label><input type="date" id="trip-date-start"></div>
|
||
<div class="field"><label class="field-label">Chegada</label><input type="date" id="trip-date-end"></div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Horímetro saída</label><input type="number" step="0.1" inputmode="decimal" id="trip-hours-start" placeholder="0.0"></div>
|
||
<div class="field"><label class="field-label">Horímetro chegada</label><input type="number" step="0.1" inputmode="decimal" id="trip-hours-end" placeholder="0.0"></div>
|
||
</div>
|
||
<div class="field"><label class="field-label">Tripulação</label>
|
||
<div class="pax-input-row"><input id="pax-input" placeholder="Nome a bordo" onkeydown="if(event.key==='Enter'){event.preventDefault();addPax()}"><button class="btn btn-sm" onclick="addPax()">Anotar</button></div>
|
||
<div class="pax-list" id="pax-list"></div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Vento</label><input type="text" id="trip-wind" placeholder="12-18 NE"></div>
|
||
<div class="field"><label class="field-label">Distância (mn)</label><input type="number" step="0.1" inputmode="decimal" id="trip-distance"></div>
|
||
</div>
|
||
<div id="trip-track-info"></div>
|
||
<div class="field"><label class="field-label">Notas de bordo</label><textarea id="trip-notes" placeholder="Condições do mar, ocorrências, momentos..."></textarea></div>
|
||
<div class="field"><label class="field-label">Mídia & Registros</label>
|
||
<div class="media-toolbar">
|
||
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 7h4l2-3h6l2 3h4v13H3z"/><circle cx="12" cy="13" r="4"/></svg>Fotografar<input type="file" accept="image/*" capture="environment" onchange="handleMediaUpload(event,'photo','trip')" style="display:none"></label>
|
||
<button class="media-btn" onclick="openRecorder('trip')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>Áudio</button>
|
||
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="12" rx="1"/><path d="M16 10l5-3v10l-5-3z"/></svg>Vídeo<input type="file" accept="video/*" capture="environment" onchange="handleMediaUpload(event,'video','trip')" style="display:none"></label>
|
||
</div>
|
||
<div class="modal-media-grid" id="trip-media-grid"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeModal('trip-modal')">Cancelar</button><button class="btn btn-primary" onclick="saveTrip()">Lançar no diário</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal Reparo -->
|
||
<div class="modal-backdrop" id="maint-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3 id="maint-modal-title">Novo Reparo</h3><button class="icon-btn" onclick="closeModal('maint-modal')">✕</button></div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="maint-id"><input type="hidden" id="maint-from-pending">
|
||
<div class="field"><label class="field-label">Serviço realizado</label><input id="maint-title" placeholder="ex: Troca de óleo do motor"></div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Data</label><input type="date" id="maint-date"></div>
|
||
<div class="field"><label class="field-label">Horímetro</label><input type="number" step="0.1" inputmode="decimal" id="maint-hours"></div>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Categoria</label><select id="maint-category"><option value="motor">Motor</option><option value="velas">Velas & Cordames</option><option value="casco">Casco & Pintura</option><option value="eletrica">Elétrica</option><option value="eletronica">Eletrônica</option><option value="seguranca">Segurança</option><option value="outros">Outros</option></select></div>
|
||
<div class="field"><label class="field-label">Custo</label><input type="number" step="0.01" inputmode="decimal" id="maint-cost" placeholder="R$"></div>
|
||
</div>
|
||
<div class="field"><label class="field-label">Estaleiro / Prestador</label><input id="maint-vendor" placeholder="ex: Estaleiro Costa Verde"></div>
|
||
<div class="field"><label class="field-label">Detalhes & peças</label><textarea id="maint-notes" placeholder="Peças trocadas, marcas, próxima revisão..."></textarea></div>
|
||
<div class="field"><label class="field-label">Comprovantes & mídia</label>
|
||
<div class="media-toolbar">
|
||
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 7h4l2-3h6l2 3h4v13H3z"/><circle cx="12" cy="13" r="4"/></svg>Fotografar<input type="file" accept="image/*" capture="environment" onchange="handleMediaUpload(event,'photo','maint')" style="display:none"></label>
|
||
<button class="media-btn" onclick="openRecorder('maint')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>Áudio</button>
|
||
<label class="media-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="6" width="13" height="12" rx="1"/><path d="M16 10l5-3v10l-5-3z"/></svg>Vídeo<input type="file" accept="video/*" capture="environment" onchange="handleMediaUpload(event,'video','maint')" style="display:none"></label>
|
||
</div>
|
||
<div class="modal-media-grid" id="maint-media-grid"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeModal('maint-modal')">Cancelar</button><button class="btn btn-primary" onclick="saveMaint()">Lançar no diário</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal Pendência -->
|
||
<div class="modal-backdrop" id="pending-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3 id="pending-modal-title">Nova Pendência</h3><button class="icon-btn" onclick="closeModal('pending-modal')">✕</button></div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="pending-id">
|
||
<div class="field"><label class="field-label">O que precisa fazer</label><input id="pending-title" placeholder="ex: Trocar bateria"></div>
|
||
<div class="field"><label class="field-label">Categoria</label><select id="pending-category"><option value="motor">Motor</option><option value="velas">Velas & Cordames</option><option value="casco">Casco & Pintura</option><option value="eletrica">Elétrica</option><option value="eletronica">Eletrônica</option><option value="seguranca">Segurança</option><option value="outros">Outros</option></select></div>
|
||
<div class="field-row">
|
||
<div class="field"><label class="field-label">Data prevista</label><input type="date" id="pending-due-date"><div class="field-hint">opcional</div></div>
|
||
<div class="field"><label class="field-label">Ao chegar em (h)</label><input type="number" step="0.1" inputmode="decimal" id="pending-due-hours" placeholder="ex: 250"><div class="field-hint">horímetro alvo</div></div>
|
||
</div>
|
||
<div class="field"><label class="field-label">Custo estimado</label><input type="number" step="0.01" inputmode="decimal" id="pending-est-cost" placeholder="R$"></div>
|
||
<div class="field"><label class="field-label">Prioridade</label>
|
||
<div class="priority-row">
|
||
<label><input type="radio" name="pending-priority" value="low"><span>Baixa</span></label>
|
||
<label><input type="radio" name="pending-priority" value="normal" checked><span>Normal</span></label>
|
||
<label><input type="radio" name="pending-priority" value="high"><span>Alta</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="field"><label class="field-label">Detalhes</label><textarea id="pending-notes" placeholder="Especificações, fornecedores, links..."></textarea></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeModal('pending-modal')">Cancelar</button><button class="btn btn-primary" onclick="savePending()">Anotar</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recorder -->
|
||
<div class="modal-backdrop" id="recorder-modal">
|
||
<div class="modal recorder-modal">
|
||
<div class="recorder-body">
|
||
<button class="rec-circle" id="rec-button" onclick="toggleRecording()"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></button>
|
||
<div class="rec-time" id="rec-time">00:00</div>
|
||
<div class="rec-status" id="rec-status">toque para gravar</div>
|
||
<div style="display:flex;gap:8px;justify-content:center">
|
||
<button class="btn" onclick="cancelRecording()">Cancelar</button>
|
||
<button class="btn btn-primary" id="rec-save" onclick="saveRecording()" style="display:none">Salvar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tracking -->
|
||
<div class="modal-backdrop" id="tracking-modal" style="align-items:stretch;padding:0">
|
||
<div class="modal tracking-modal">
|
||
<div class="tracking-head">
|
||
<h3><span class="live-dot"></span>Rastreio em curso</h3>
|
||
<button class="icon-btn" onclick="minimizeTracking()" style="color:var(--brass-bright)">−</button>
|
||
</div>
|
||
<div class="tracking-stats">
|
||
<div><div class="t-stat-label">Velocidade</div><div class="t-stat-value"><span id="t-speed">0.0</span><span class="t-stat-unit">nós</span></div></div>
|
||
<div><div class="t-stat-label">Distância</div><div class="t-stat-value"><span id="t-distance">0.00</span><span class="t-stat-unit">mn</span></div></div>
|
||
<div><div class="t-stat-label">Tempo</div><div class="t-stat-value" id="t-duration">00:00</div></div>
|
||
<div><div class="t-stat-label">Vel. máxima</div><div class="t-stat-value"><span id="t-maxspeed">0.0</span><span class="t-stat-unit">nós</span></div></div>
|
||
</div>
|
||
<div class="tracking-map" id="tracking-map"></div>
|
||
<div class="tracking-foot">
|
||
<button class="btn" onclick="minimizeTracking()">Minimizar</button>
|
||
<button class="btn btn-danger" onclick="stopTracking()">Parar & Salvar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Map Viewer -->
|
||
<div class="modal-backdrop" id="map-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3>Carta da travessia</h3><button class="icon-btn" onclick="closeMapModal()">✕</button></div>
|
||
<div class="modal-body">
|
||
<div id="map-modal-content"></div>
|
||
<div class="map-stats" id="map-modal-stats"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeMapModal()">Fechar</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Media Viewer -->
|
||
<div class="viewer-backdrop" id="viewer-modal" onclick="if(event.target===this)closeViewer()">
|
||
<button class="viewer-close" onclick="closeViewer()">✕</button>
|
||
<div class="viewer-content" id="viewer-content"></div>
|
||
<div class="viewer-actions">
|
||
<button class="btn" onclick="downloadCurrentMedia()">Baixar</button>
|
||
<button class="btn btn-danger" onclick="deleteCurrentMedia()">Apagar</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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-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>
|
||
<div class="field">
|
||
<label class="field-label">Raio de segurança</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>
|
||
<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>
|
||
<button class="btn btn-block btn-sm" onclick="openContactsModal()">Configurar contatos</button>
|
||
</div>
|
||
<div style="font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);line-height:1.55;padding:10px 12px;background:var(--sun-soft);border-left:2px solid var(--sun);margin-top:14px">
|
||
Mantenha o app aberto e o celular conectado à energia. O GPS pode ser pausado pelo Android se a tela for desligada por muito tempo.
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn" onclick="closeModal('anchor-setup-modal')">Cancelar</button>
|
||
<button class="btn btn-primary" id="anchor-confirm-btn" onclick="confirmAnchor()" disabled>Iniciar vigia</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Anchor Watch Fullscreen -->
|
||
<div class="modal-backdrop" id="anchor-watch-modal" style="align-items:stretch;padding:0">
|
||
<div class="modal anchor-modal">
|
||
<div class="anchor-head">
|
||
<h3 id="anchor-head-title">Vigia de Fundeio</h3>
|
||
<button class="icon-btn" onclick="minimizeAnchor()" style="color:var(--brass-bright)">−</button>
|
||
</div>
|
||
<div class="anchor-stats-bar">
|
||
<div class="anchor-stat"><div class="anchor-stat-lbl">Distância</div><div class="anchor-stat-val" id="aw-distance">0<span class="anchor-stat-unit">m</span></div></div>
|
||
<div class="anchor-stat"><div class="anchor-stat-lbl">Raio</div><div class="anchor-stat-val" id="aw-radius">50<span class="anchor-stat-unit">m</span></div></div>
|
||
<div class="anchor-stat"><div class="anchor-stat-lbl">Tempo</div><div class="anchor-stat-val" id="aw-duration">00:00</div></div>
|
||
</div>
|
||
<div class="anchor-map" id="anchor-map"></div>
|
||
<div class="anchor-foot">
|
||
<div class="anchor-radius-row">
|
||
<label>Raio</label>
|
||
<input type="range" id="aw-radius-slider" min="15" max="200" step="5" value="50" oninput="adjustAnchorRadius(this.value)">
|
||
<span class="anchor-radius-val" id="aw-radius-slider-val">50 m</span>
|
||
</div>
|
||
<div class="anchor-foot-row">
|
||
<button class="btn btn-sm" onclick="recenterSwing()" title="Boat aqui é o novo centro de giro">⊙ Recentrar</button>
|
||
<button class="btn btn-sm" id="auto-recenter-btn" onclick="toggleAutoRecenter()">Auto: off</button>
|
||
<button class="btn btn-sm" onclick="testAlarmSound()">Testar alarme</button>
|
||
</div>
|
||
<div class="anchor-foot-row">
|
||
<button class="btn" onclick="minimizeAnchor()">Minimizar</button>
|
||
<button class="btn btn-danger" onclick="stopAnchorWatch()">Parar vigia</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alarm Screen -->
|
||
<div class="alarm-backdrop" id="alarm-screen">
|
||
<svg class="alarm-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 L22 20 H2 Z"/><line x1="12" y1="9" x2="12" y2="14"/><circle cx="12" cy="17.5" r="1" fill="currentColor"/></svg>
|
||
<div class="alarm-title" id="alarm-title">Shivao derivando!</div>
|
||
<div class="alarm-sub">Saiu da posição de fundeio</div>
|
||
<div class="alarm-distance" id="alarm-distance">0<span style="font-size:24px">m</span></div>
|
||
<div class="alarm-distance-lbl">distância da âncora</div>
|
||
<div class="alarm-actions" id="alarm-actions"></div>
|
||
<button class="alarm-dismiss" onclick="dismissAlarm()">Cancelar alarme</button>
|
||
</div>
|
||
|
||
<!-- Contacts Modal -->
|
||
<div class="modal-backdrop" id="contacts-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3>Contatos de emergência</h3><button class="icon-btn" onclick="closeModal('contacts-modal')">✕</button></div>
|
||
<div class="modal-body">
|
||
<div id="contacts-list"></div>
|
||
<div style="border-top:1px solid var(--rule-soft);padding-top:14px;margin-top:14px">
|
||
<div class="field"><label class="field-label">Nome</label><input id="contact-name" placeholder="ex: Maria (esposa)"></div>
|
||
<div class="field"><label class="field-label">Telefone com DDI</label><input id="contact-phone" placeholder="ex: 5521999998888" inputmode="tel"><div class="field-hint">só números, com código do país (55 = Brasil)</div></div>
|
||
<div class="field"><label class="field-label">E-mail (opcional)</label><input id="contact-email" type="email" placeholder="contato@email.com"></div>
|
||
<div class="field"><label class="field-label">Canais de envio</label>
|
||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
|
||
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid var(--rule);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer"><input type="checkbox" id="contact-ch-wa" checked>WhatsApp</label>
|
||
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid var(--rule);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer"><input type="checkbox" id="contact-ch-sms" checked>SMS</label>
|
||
<label style="display:flex;align-items:center;gap:6px;padding:8px;border:1px solid var(--rule);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer"><input type="checkbox" id="contact-ch-mail">E-mail</label>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary btn-block" onclick="addContact()">Adicionar contato</button>
|
||
</div>
|
||
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--rule-soft)">
|
||
<label class="field-label">Mensagem do alerta</label>
|
||
<textarea id="alarm-message-template" style="width:100%;padding:10px 12px;background:var(--bg-canvas);border:1px solid var(--rule);font-family:var(--f-display);font-style:italic;font-size:14px;min-height:90px;margin-top:5px" placeholder="Mensagem padrão...">🚨 ALERTA: Shivao saiu da posição de fundeio.
|
||
Estou em {LAT}, {LNG}
|
||
Mapa: https://maps.google.com/?q={LAT},{LNG}
|
||
Hora: {HORA}</textarea>
|
||
<div class="field-hint">Use {LAT}, {LNG}, {HORA}, {DIST}, {NOME} como variáveis.</div>
|
||
</div>
|
||
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--rule-soft)">
|
||
<div style="font-family:var(--f-display);font-style:italic;font-weight:500;font-size:16px;color:var(--ink-deep);margin-bottom:4px">Bots & Webhooks · envio automático</div>
|
||
<div style="font-family:var(--f-display);font-style:italic;font-size:13px;color:var(--sepia);line-height:1.55;margin-bottom:14px">Disparam imediatamente quando o alarme toca. Sem necessidade de tocar em botão.</div>
|
||
|
||
<div class="field"><label class="field-label">Telegram · token do bot</label>
|
||
<input id="wh-tg-token" type="password" placeholder="123456:ABC-DEF..." style="font-family:var(--f-mono);font-size:12px"></div>
|
||
<div class="field"><label class="field-label">Telegram · chat IDs (vírgula)</label>
|
||
<input id="wh-tg-chats" placeholder="ex: 123456789, -100123456" style="font-family:var(--f-mono);font-size:12px">
|
||
<div class="field-hint">Crie bot em @BotFather, envie /start, depois acesse api.telegram.org/bot<TOKEN>/getUpdates para ver os IDs.</div>
|
||
</div>
|
||
|
||
<div class="field"><label class="field-label">Discord · webhook URL</label>
|
||
<input id="wh-discord" type="url" placeholder="https://discord.com/api/webhooks/..." style="font-family:var(--f-mono);font-size:11px">
|
||
<div class="field-hint">No Discord: editar canal → Integrações → Webhooks → Novo webhook → copiar URL.</div>
|
||
</div>
|
||
|
||
<div class="field"><label class="field-label">Webhook genérico · URL</label>
|
||
<input id="wh-generic" type="url" placeholder="https://..." style="font-family:var(--f-mono);font-size:11px">
|
||
<div class="field-hint">Para n8n, IFTTT, Zapier, ntfy.sh, etc. Recebe POST JSON.</div>
|
||
</div>
|
||
|
||
<button class="btn btn-block btn-sm" onclick="testWebhooks()">Disparar teste nos webhooks</button>
|
||
<div id="webhook-test-result" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn btn-primary" onclick="saveContactsConfig()">Salvar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Anchor History Modal -->
|
||
<div class="modal-backdrop" id="anchor-history-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3>Histórico de Fundeios</h3><button class="icon-btn" onclick="closeModal('anchor-history-modal')">✕</button></div>
|
||
<div class="modal-body" id="anchor-history-body"></div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeModal('anchor-history-modal')">Fechar</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Anchor History Map Modal -->
|
||
<div class="modal-backdrop" id="anchor-history-map-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3 id="ahm-title">Fundeio</h3><button class="icon-btn" onclick="closeAnchorHistoryMap()">✕</button></div>
|
||
<div class="modal-body">
|
||
<div id="anchor-history-map" style="height:55vh;min-height:280px;background:var(--bg-aged);border:1px solid var(--rule)"></div>
|
||
<div class="map-stats" id="ahm-stats"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeAnchorHistoryMap()">Fechar</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Checklists Modal -->
|
||
<div class="modal-backdrop" id="checklists-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3 id="checklists-modal-title">Checklists de Bordo</h3><button class="icon-btn" onclick="closeModal('checklists-modal')">✕</button></div>
|
||
<div class="modal-body" id="checklists-body"></div>
|
||
<div class="modal-foot"><button class="btn" onclick="closeModal('checklists-modal')">Fechar</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Run Checklist Modal -->
|
||
<div class="modal-backdrop" id="run-checklist-modal">
|
||
<div class="modal">
|
||
<div class="modal-head"><h3 id="run-checklist-title">Checklist</h3><button class="icon-btn" onclick="closeModal('run-checklist-modal')">✕</button></div>
|
||
<div class="modal-body" id="run-checklist-body"></div>
|
||
<div class="modal-foot">
|
||
<div id="run-checklist-progress" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);letter-spacing:.04em;margin-right:auto;align-self:center"></div>
|
||
<button class="btn" onclick="closeModal('run-checklist-modal')">Fechar</button>
|
||
<button class="btn btn-primary" onclick="resetCurrentChecklist()">Limpar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Zone Editor Modal -->
|
||
<div class="modal-backdrop" id="zone-edit-modal" style="align-items:stretch;padding:0">
|
||
<div class="modal anchor-modal">
|
||
<div class="anchor-head">
|
||
<h3 id="zone-edit-title">Nova Zona</h3>
|
||
<button class="icon-btn" onclick="closeZoneEditor()" style="color:var(--brass-bright)">✕</button>
|
||
</div>
|
||
<div style="background:var(--ink-deep);color:var(--bg-paper);padding:10px 18px;font-family:var(--f-mono);font-size:11px;letter-spacing:.05em" id="zone-edit-help">Toque no mapa para marcar o centro da zona</div>
|
||
<div class="anchor-map" id="zone-map"></div>
|
||
<div class="anchor-foot">
|
||
<div class="anchor-radius-row">
|
||
<label>Raio</label>
|
||
<input type="range" id="zone-radius" min="20" max="2000" step="10" value="100" oninput="updateZoneRadius(this.value)">
|
||
<span class="anchor-radius-val" id="zone-radius-val">100 m</span>
|
||
</div>
|
||
<div class="field-row">
|
||
<div class="field" style="margin:0"><label class="field-label">Nome</label><input id="zone-name" placeholder="ex: Pedras submersas"></div>
|
||
<div class="field" style="margin:0"><label class="field-label">Tipo</label><select id="zone-type"><option value="forbidden">⛔ Proibida</option><option value="attention">⚠️ Atenção</option></select></div>
|
||
</div>
|
||
<div class="anchor-foot-row">
|
||
<button class="btn" onclick="closeZoneEditor()">Cancelar</button>
|
||
<button class="btn btn-danger" id="zone-delete-btn" onclick="deleteZone()" style="display:none">Apagar</button>
|
||
<button class="btn btn-primary" onclick="saveZone()">Salvar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<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},webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
|
||
const STORAGE_KEY='diario_bordo_v3';
|
||
const TRACKING_KEY='diario_tracking_v3';
|
||
const ANCHOR_KEY='diario_anchor_v3';
|
||
let pendingFilter='active';
|
||
let editingMedia=[];
|
||
let mediaUrlCache=new Map();
|
||
let db,mapInstance=null,trackingMap=null,trackingPolyline=null,trackingMarker=null;
|
||
|
||
const COMPASS_ROSE_SVG=`<svg class="empty-rose" viewBox="0 0 100 100"><g fill="none" stroke="currentColor" stroke-width=".8"><circle cx="50" cy="50" r="46"/><circle cx="50" cy="50" r="34"/><circle cx="50" cy="50" r="2.5" fill="currentColor"/></g><g fill="currentColor"><path d="M50 6 L53 50 L50 50 Z"/><path d="M50 6 L47 50 L50 50 Z" opacity=".55"/><path d="M50 94 L47 50 L50 50 Z"/><path d="M50 94 L53 50 L50 50 Z" opacity=".55"/><path d="M94 50 L50 47 L50 50 Z" opacity=".7"/><path d="M94 50 L50 53 L50 50 Z" opacity=".4"/><path d="M6 50 L50 53 L50 50 Z" opacity=".7"/><path d="M6 50 L50 47 L50 50 Z" opacity=".4"/></g></svg>`;
|
||
|
||
function openDB(){return new Promise((r,j)=>{const q=indexedDB.open('diario_bordo_db',1);q.onupgradeneeded=e=>{const d=e.target.result;if(!d.objectStoreNames.contains('media')){const s=d.createObjectStore('media',{keyPath:'id'});s.createIndex('parent','parentId',{unique:false})}};q.onsuccess=e=>{db=e.target.result;r(db)};q.onerror=e=>j(e)})}
|
||
function dbGet(id){return new Promise((r,j)=>{const q=db.transaction('media','readonly').objectStore('media').get(id);q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
|
||
function dbPut(item){return new Promise((r,j)=>{const q=db.transaction('media','readwrite').objectStore('media').put(item);q.onsuccess=()=>r(q.result);q.onerror=()=>j(q.error)})}
|
||
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)}
|
||
// popular checklists padrão se vazio
|
||
if(!state.checklists.length){
|
||
state.checklists=[
|
||
{id:'cl_safe',name:'Segurança · pré-saída',items:['Coletes salva-vidas a bordo','Rádio VHF testado (canal 16)','Sinalizadores no prazo','Extintor verificado','Bomba de porão funcionando','Kit de primeiros socorros','Plano de viagem comunicado em terra']},
|
||
{id:'cl_motor',name:'Motor · pré-partida',items:['Nível de óleo OK','Nível de combustível suficiente','Filtro de água do mar limpo','Cinta do alternador OK','Válvula da água do mar aberta','Hélice livre de cordas/algas','Partida testada em ponto morto']},
|
||
{id:'cl_vela',name:'Vela · pré-içar',items:['Velas escolhidas conforme vento','Cordames sem nós ou enroscos','Bujarrona pronta','Mestra com cunha de saída livre','Driças nos pontos','Escotas correndo livre']},
|
||
{id:'cl_anchor',name:'Fundeio',items:['Profundidade adequada','Tipo de fundo apropriado','Linha de fundeio: ≥ 5x profundidade','Comprimento de cabo conferido','Vigia configurada no app','Posição de outros barcos','Distância de costa segura']},
|
||
{id:'cl_long',name:'Travessia longa',items:['Previsão do tempo (24h+)','Combustível para 1.5x distância','Água potável suficiente','Comida não-perecível extra','Bateria reserva carregada','Cartas náuticas atualizadas','Plano de portos alternativos','Tripulação descansada']}
|
||
].map(c=>({...c,items:c.items.map(text=>({id:uid(),text}))}));
|
||
saveState();
|
||
}
|
||
}
|
||
function saveState(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch(e){toast('Erro ao salvar')}}
|
||
|
||
function uid(){return Date.now().toString(36)+Math.random().toString(36).slice(2,7)}
|
||
const MESES=['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
|
||
function fmtDate(iso){if(!iso)return'—';const[y,m,d]=iso.split('-');return`${d} ${MESES[parseInt(m)-1]} ${y.slice(2)}`}
|
||
function fmtDateLong(iso){if(!iso)return'—';const[y,m,d]=iso.split('-');return`${d} ${MESES[parseInt(m)-1]} ${y}`}
|
||
function fmtDateRange(a,b){if(!a&&!b)return'—';if(a&&b&&a!==b)return`${fmtDate(a)} → ${fmtDate(b)}`;return fmtDate(a||b)}
|
||
function fmtHours(h){if(h==null||h==='')return'—';return Number(h).toFixed(1)}
|
||
function fmtMoney(v){if(!v)return'R$ 0,00';return'R$ '+Number(v).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})}
|
||
function escapeHtml(s){if(!s)return'';return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c])}
|
||
function todayISO(){return new Date().toISOString().slice(0,10)}
|
||
function daysBetween(a,b){return Math.round((new Date(b)-new Date(a))/86400000)}
|
||
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toast._tm);toast._tm=setTimeout(()=>t.classList.remove('show'),2400)}
|
||
function getMediaUrl(item){if(mediaUrlCache.has(item.id))return mediaUrlCache.get(item.id);const u=URL.createObjectURL(item.blob);mediaUrlCache.set(item.id,u);return u}
|
||
function clearMediaCache(){mediaUrlCache.forEach(u=>URL.revokeObjectURL(u));mediaUrlCache.clear()}
|
||
|
||
function haversine(p1,p2){const R=6371000,r=Math.PI/180;const a1=p1.lat*r,a2=p2.lat*r;const dA=(p2.lat-p1.lat)*r,dG=(p2.lng-p1.lng)*r;const a=Math.sin(dA/2)**2+Math.cos(a1)*Math.cos(a2)*Math.sin(dG/2)**2;return 2*R*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))}
|
||
function metersToNM(m){return m/1852}
|
||
function msToKnots(ms){return ms*1.94384}
|
||
function fmtDuration(s){const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);if(h>0)return`${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;return`${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`}
|
||
|
||
const tracking={active:false,watchId:null,startedAt:null,points:[],totalDist:0,maxSpeed:0,wakeLock:null};
|
||
let trackingInterval=null;
|
||
|
||
function loadTrackingState(){try{const raw=localStorage.getItem(TRACKING_KEY);if(raw){const t=JSON.parse(raw);if(t.active&&confirm('Encontrei um rastreio em andamento. Continuar?')){Object.assign(tracking,t);tracking.watchId=null;startGPS();renderGPSBanner()}else{localStorage.removeItem(TRACKING_KEY)}}}catch(e){}}
|
||
function saveTrackingState(){try{const{watchId,wakeLock,...r}=tracking;localStorage.setItem(TRACKING_KEY,JSON.stringify(r))}catch(e){}}
|
||
async function requestWakeLock(){try{if('wakeLock' in navigator){tracking.wakeLock=await navigator.wakeLock.request('screen');tracking.wakeLock.addEventListener('release',()=>{tracking.wakeLock=null;if(tracking.active)setTimeout(()=>{if(tracking.active&&!tracking.wakeLock)requestWakeLock()},1000)})}}catch(e){console.warn('wake lock tracking:',e)}}
|
||
async function releaseWakeLock(){try{if(tracking.wakeLock){await tracking.wakeLock.release();tracking.wakeLock=null}}catch(e){}}
|
||
function startGPS(){if(!navigator.geolocation){toast('GPS não disponível');return}let r=0;function tryStart(){tracking.watchId=navigator.geolocation.watchPosition(p=>{r=0;onGPSUpdate(p)},e=>{if(e.code===e.PERMISSION_DENIED){toast('GPS sem permissão — rastreio interrompido');return}r++;const d=Math.min(1000*Math.pow(2,r-1),30000);toast(`GPS perdido (#${r}) — retentando em ${d/1000}s`);if(tracking.watchId)navigator.geolocation.clearWatch(tracking.watchId);setTimeout(()=>{if(tracking.active)tryStart()},d)},batteryGPSOptions())}tryStart()}
|
||
|
||
function onGPSUpdate(pos){
|
||
const p={lat:pos.coords.latitude,lng:pos.coords.longitude,ts:Date.now(),spd:pos.coords.speed||0,acc:pos.coords.accuracy};
|
||
if(p.acc>50)return;
|
||
const last=tracking.points[tracking.points.length-1];
|
||
if(last){const d=haversine(last,p);if(d<batteryDistanceFilter())return;if(p.ts-last.ts<batteryTimeFilter())return;tracking.totalDist+=d}
|
||
if(p.spd>tracking.maxSpeed)tracking.maxSpeed=p.spd;
|
||
tracking.points.push(p);saveTrackingState();updateTrackingUI();updateTrackingMap(p);
|
||
}
|
||
|
||
async function startTracking(){
|
||
if(tracking.active){toast('Já está rastreando');return}
|
||
if(!navigator.geolocation){toast('Sem GPS no dispositivo');return}
|
||
try{await new Promise((r,j)=>navigator.geolocation.getCurrentPosition(r,j,{enableHighAccuracy:true,timeout:15000}))}
|
||
catch(e){toast('Permita acesso ao GPS');return}
|
||
tracking.active=true;tracking.startedAt=Date.now();tracking.points=[];tracking.totalDist=0;tracking.maxSpeed=0;
|
||
saveTrackingState();await requestWakeLock();startGPS();
|
||
openTrackingModal();trackingInterval=setInterval(updateTrackingUI,1000);renderGPSBanner();
|
||
toast('Rastreio iniciado');
|
||
}
|
||
|
||
async function stopTracking(){
|
||
if(!tracking.active)return;
|
||
if(tracking.points.length<2){if(!confirm('Poucos pontos coletados. Descartar?'))return;cancelTracking();return}
|
||
if(!confirm('Parar rastreio e lançar como nova travessia?'))return;
|
||
if(tracking.watchId!=null)navigator.geolocation.clearWatch(tracking.watchId);
|
||
await releaseWakeLock();clearInterval(trackingInterval);
|
||
const td={startedAt:tracking.startedAt,endedAt:Date.now(),points:tracking.points.map(p=>({lat:p.lat,lng:p.lng,ts:p.ts,spd:p.spd})),distance:tracking.totalDist,maxSpeed:tracking.maxSpeed,avgSpeed:tracking.points.length>1?tracking.totalDist/((Date.now()-tracking.startedAt)/1000):0};
|
||
tracking.active=false;localStorage.removeItem(TRACKING_KEY);
|
||
closeModal('tracking-modal');
|
||
await openTripModal(null,td);renderGPSBanner();toast('Rastreio finalizado');
|
||
}
|
||
|
||
function cancelTracking(){if(tracking.watchId!=null)navigator.geolocation.clearWatch(tracking.watchId);releaseWakeLock();clearInterval(trackingInterval);tracking.active=false;tracking.points=[];tracking.totalDist=0;tracking.maxSpeed=0;localStorage.removeItem(TRACKING_KEY);closeModal('tracking-modal');renderGPSBanner()}
|
||
function minimizeTracking(){closeModal('tracking-modal')}
|
||
function reopenTracking(){if(tracking.active)openTrackingModal()}
|
||
|
||
function openTrackingModal(){
|
||
openModal('tracking-modal');
|
||
setTimeout(()=>{
|
||
if(!trackingMap){trackingMap=L.map('tracking-map').setView([0,0],2);L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(trackingMap)}
|
||
setTimeout(()=>trackingMap.invalidateSize(),100);
|
||
if(tracking.points.length){const ll=tracking.points.map(p=>[p.lat,p.lng]);if(trackingPolyline)trackingPolyline.remove();trackingPolyline=L.polyline(ll,{color:'#a07832',weight:4,opacity:.85}).addTo(trackingMap);const last=tracking.points[tracking.points.length-1];if(trackingMarker)trackingMarker.remove();trackingMarker=L.marker([last.lat,last.lng]).addTo(trackingMap);trackingMap.fitBounds(trackingPolyline.getBounds(),{padding:[20,20]})}
|
||
updateTrackingUI();
|
||
},50);
|
||
}
|
||
|
||
function updateTrackingMap(p){if(!trackingMap)return;const ll=tracking.points.map(x=>[x.lat,x.lng]);if(trackingPolyline)trackingPolyline.remove();trackingPolyline=L.polyline(ll,{color:'#a07832',weight:4,opacity:.85}).addTo(trackingMap);if(trackingMarker)trackingMarker.remove();trackingMarker=L.marker([p.lat,p.lng]).addTo(trackingMap);if(tracking.points.length<3)trackingMap.setView([p.lat,p.lng],15);else trackingMap.panTo([p.lat,p.lng])}
|
||
|
||
function updateTrackingUI(){
|
||
if(!tracking.active)return;
|
||
const last=tracking.points[tracking.points.length-1];
|
||
const cs=last?msToKnots(last.spd||0):0;
|
||
document.getElementById('t-speed').textContent=cs.toFixed(1);
|
||
document.getElementById('t-distance').textContent=metersToNM(tracking.totalDist).toFixed(2);
|
||
document.getElementById('t-maxspeed').textContent=msToKnots(tracking.maxSpeed).toFixed(1);
|
||
document.getElementById('t-duration').textContent=fmtDuration((Date.now()-tracking.startedAt)/1000);
|
||
const sb=document.getElementById('gps-banner');
|
||
if(sb&&sb.querySelector('#b-speed')){sb.querySelector('#b-speed').textContent=cs.toFixed(1);sb.querySelector('#b-distance').textContent=metersToNM(tracking.totalDist).toFixed(2);sb.querySelector('#b-duration').textContent=fmtDuration((Date.now()-tracking.startedAt)/1000)}
|
||
}
|
||
|
||
function renderGPSBanner(){
|
||
const el=document.getElementById('gps-banner');
|
||
if(tracking.active){
|
||
el.innerHTML=`<div class="gps-card">
|
||
<div class="gps-head"><h3>Rastreio em curso</h3></div>
|
||
<div class="gps-sub"><span class="live-dot"></span>GPS ativo · gravando trajeto</div>
|
||
<div class="gps-stats">
|
||
<div class="gps-stat"><div class="gps-stat-label">Velocidade</div><div class="gps-stat-value"><span id="b-speed">0.0</span><span class="gps-stat-unit">nós</span></div></div>
|
||
<div class="gps-stat"><div class="gps-stat-label">Distância</div><div class="gps-stat-value"><span id="b-distance">0.00</span><span class="gps-stat-unit">mn</span></div></div>
|
||
<div class="gps-stat"><div class="gps-stat-label">Tempo</div><div class="gps-stat-value" id="b-duration">00:00</div></div>
|
||
</div>
|
||
<div class="gps-actions">
|
||
<button class="btn" onclick="reopenTracking()" style="background:transparent;color:var(--brass-bright);border-color:var(--brass)">Ver carta</button>
|
||
<button class="btn btn-danger" onclick="stopTracking()" style="background:var(--storm);color:#fff;border-color:var(--storm)">Parar</button>
|
||
</div>
|
||
</div>`;
|
||
updateTrackingUI();
|
||
}else{
|
||
el.innerHTML=`<div class="gps-card idle">
|
||
<div class="gps-head"><h3>Rastreio GPS</h3></div>
|
||
<div class="gps-sub">Trace sua travessia · calcule distância & velocidade em tempo real</div>
|
||
<div class="gps-actions"><button class="btn btn-primary btn-block btn-big" onclick="startTracking()">Iniciar rastreio</button></div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
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()})}
|
||
|
||
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()}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()}
|
||
function openModal(id){document.getElementById(id).classList.add('show')}
|
||
function closeModal(id){document.getElementById(id).classList.remove('show')}
|
||
document.querySelectorAll('.modal-backdrop').forEach(b=>{b.addEventListener('click',e=>{if(e.target===b&&b.id!=='tracking-modal')b.classList.remove('show')})});
|
||
|
||
let editingPax=[];
|
||
function renderPaxList(){document.getElementById('pax-list').innerHTML=editingPax.map((p,i)=>`<span class="pax-tag">${escapeHtml(p)}<button onclick="removePax(${i})">×</button></span>`).join('')}
|
||
function addPax(){const i=document.getElementById('pax-input'),v=i.value.trim();if(!v)return;editingPax.push(v);i.value='';renderPaxList()}
|
||
function removePax(i){editingPax.splice(i,1);renderPaxList()}
|
||
|
||
async function handleMediaUpload(event,kind,pt){const f=event.target.files[0];if(!f)return;editingMedia.push({id:uid(),kind,blob:f,mime:f.type,parentType:pt});await renderModalMedia(pt);event.target.value=''}
|
||
async function renderModalMedia(pt){const grid=document.getElementById(pt+'-media-grid');grid.innerHTML='';for(const it of editingMedia){const url=getMediaUrl(it);const div=document.createElement('div');div.className='media-item '+it.kind;if(it.kind==='photo')div.innerHTML=`<img src="${url}" alt=""><button class="media-remove" onclick="removeEditingMedia('${it.id}','${pt}')">×</button>`;else if(it.kind==='video')div.innerHTML=`<video src="${url}" muted></video><button class="media-remove" onclick="removeEditingMedia('${it.id}','${pt}')">×</button>`;else div.innerHTML=`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg><button class="media-remove" onclick="removeEditingMedia('${it.id}','${pt}')">×</button>`;grid.appendChild(div)}}
|
||
function removeEditingMedia(id,pt){editingMedia=editingMedia.filter(m=>m.id!==id);if(mediaUrlCache.has(id)){URL.revokeObjectURL(mediaUrlCache.get(id));mediaUrlCache.delete(id)}renderModalMedia(pt)}
|
||
async function loadEntryMedia(pid){const all=await dbAll();return all.filter(m=>m.parentId===pid)}
|
||
async function saveEditingMediaFor(pid,pt){for(const i of editingMedia){if(!i.saved)await dbPut({id:i.id,kind:i.kind,blob:i.blob,mime:i.mime,parentId:pid,parentType:pt,createdAt:Date.now()})}const ex=await loadEntryMedia(pid);const keep=new Set(editingMedia.map(m=>m.id));for(const e of ex)if(!keep.has(e.id))await dbDelete(e.id)}
|
||
async function deleteAllMediaFor(pid){const ex=await loadEntryMedia(pid);for(const e of ex)await dbDelete(e.id)}
|
||
|
||
let mediaRecorder=null,recordedChunks=[],recordedBlob=null,recStartTime=0,recTimer=null,recParentType=null;
|
||
async function openRecorder(pt){recParentType=pt;recordedBlob=null;recordedChunks=[];const b=document.getElementById('rec-button');b.classList.remove('recording');b.innerHTML='<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg>';document.getElementById('rec-time').textContent='00:00';document.getElementById('rec-status').textContent='toque para gravar';document.getElementById('rec-save').style.display='none';openModal('recorder-modal')}
|
||
async function toggleRecording(){
|
||
if(!mediaRecorder||mediaRecorder.state==='inactive'){
|
||
try{
|
||
const s=await navigator.mediaDevices.getUserMedia({audio:true});
|
||
const m=MediaRecorder.isTypeSupported('audio/webm;codecs=opus')?'audio/webm;codecs=opus':MediaRecorder.isTypeSupported('audio/mp4')?'audio/mp4':'';
|
||
mediaRecorder=new MediaRecorder(s,m?{mimeType:m}:{});
|
||
recordedChunks=[];
|
||
mediaRecorder.ondataavailable=e=>{if(e.data.size>0)recordedChunks.push(e.data)};
|
||
mediaRecorder.onstop=()=>{
|
||
recordedBlob=new Blob(recordedChunks,{type:mediaRecorder.mimeType||'audio/webm'});
|
||
s.getTracks().forEach(t=>t.stop());
|
||
document.getElementById('rec-status').textContent='gravação pronta';
|
||
document.getElementById('rec-save').style.display='inline-flex';
|
||
const b=document.getElementById('rec-button');b.classList.remove('recording');b.innerHTML='<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg>';
|
||
clearInterval(recTimer);
|
||
};
|
||
mediaRecorder.start();recStartTime=Date.now();
|
||
const b=document.getElementById('rec-button');b.classList.add('recording');b.innerHTML='<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10"/></svg>';
|
||
document.getElementById('rec-status').textContent='gravando · toque para parar';
|
||
recTimer=setInterval(()=>{const sec=Math.floor((Date.now()-recStartTime)/1000),mm=Math.floor(sec/60),ss=sec%60;document.getElementById('rec-time').textContent=`${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`},200);
|
||
}catch(e){toast('Permita acesso ao microfone')}
|
||
}else{mediaRecorder.stop()}
|
||
}
|
||
function cancelRecording(){if(mediaRecorder&&mediaRecorder.state==='recording')mediaRecorder.stop();clearInterval(recTimer);recordedBlob=null;closeModal('recorder-modal')}
|
||
async function saveRecording(){if(!recordedBlob)return;editingMedia.push({id:uid(),kind:'audio',blob:recordedBlob,mime:recordedBlob.type,parentType:recParentType});await renderModalMedia(recParentType);recordedBlob=null;closeModal('recorder-modal')}
|
||
|
||
async function openTripModal(id,trackData){
|
||
document.getElementById('trip-id').value=id||'';
|
||
document.getElementById('trip-modal-title').textContent=id?'Editar Travessia':(trackData?'Lançar Travessia':'Nova Travessia');
|
||
editingMedia=[];
|
||
document.getElementById('trip-track-data').value=trackData?JSON.stringify(trackData):'';
|
||
if(id){
|
||
const t=state.trips.find(x=>x.id===id);if(!t)return;
|
||
document.getElementById('trip-destination').value=t.destination||'';
|
||
document.getElementById('trip-date-start').value=t.dateStart||'';
|
||
document.getElementById('trip-date-end').value=t.dateEnd||'';
|
||
document.getElementById('trip-hours-start').value=t.hoursStart??'';
|
||
document.getElementById('trip-hours-end').value=t.hoursEnd??'';
|
||
document.getElementById('trip-wind').value=t.wind||'';
|
||
document.getElementById('trip-distance').value=t.distance??'';
|
||
document.getElementById('trip-notes').value=t.notes||'';
|
||
editingPax=[...(t.passengers||[])];
|
||
const md=await loadEntryMedia(id);
|
||
editingMedia=md.map(m=>({...m,saved:true,parentType:'trip'}));
|
||
document.getElementById('trip-track-info').innerHTML=t.track?renderTrackInfo(t.track):'';
|
||
}else{
|
||
['trip-destination','trip-date-end','trip-hours-start','trip-hours-end','trip-wind','trip-notes'].forEach(k=>document.getElementById(k).value='');
|
||
document.getElementById('trip-date-start').value=todayISO();editingPax=[];
|
||
if(trackData){
|
||
const dn=metersToNM(trackData.distance);
|
||
document.getElementById('trip-distance').value=dn.toFixed(2);
|
||
document.getElementById('trip-date-start').value=new Date(trackData.startedAt).toISOString().slice(0,10);
|
||
document.getElementById('trip-date-end').value=new Date(trackData.endedAt).toISOString().slice(0,10);
|
||
document.getElementById('trip-track-info').innerHTML=renderTrackInfo(trackData);
|
||
}else{document.getElementById('trip-distance').value='';document.getElementById('trip-track-info').innerHTML=''}
|
||
}
|
||
renderPaxList();await renderModalMedia('trip');openModal('trip-modal');
|
||
}
|
||
|
||
function renderTrackInfo(t){
|
||
const dur=(t.endedAt-t.startedAt)/1000;
|
||
const dn=metersToNM(t.distance),mk=msToKnots(t.maxSpeed),ak=msToKnots(t.avgSpeed||t.distance/dur);
|
||
return `<div class="field"><label class="field-label">Carta de Rastreio</label>
|
||
<div class="map-stats" style="margin-top:0">
|
||
<div class="map-stat"><span class="map-stat-num">${dn.toFixed(2)}</span><span class="map-stat-lbl">milhas</span></div>
|
||
<div class="map-stat"><span class="map-stat-num">${ak.toFixed(1)}</span><span class="map-stat-lbl">nós méd</span></div>
|
||
<div class="map-stat"><span class="map-stat-num">${mk.toFixed(1)}</span><span class="map-stat-lbl">nós máx</span></div>
|
||
</div>
|
||
<div style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:6px;letter-spacing:.05em">${t.points.length} pontos · ${fmtDuration(dur)} navegando</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function saveTrip(){
|
||
const id=document.getElementById('trip-id').value;
|
||
const dest=document.getElementById('trip-destination').value.trim();
|
||
const ds=document.getElementById('trip-date-start').value;
|
||
if(!dest&&!ds){toast('Informe destino ou data');return}
|
||
const tid=id||uid();
|
||
const tj=document.getElementById('trip-track-data').value;
|
||
let track=null;if(tj){try{track=JSON.parse(tj)}catch(e){}}
|
||
const data={id:tid,destination:dest,dateStart:ds,dateEnd:document.getElementById('trip-date-end').value,hoursStart:parseFloat(document.getElementById('trip-hours-start').value)||null,hoursEnd:parseFloat(document.getElementById('trip-hours-end').value)||null,wind:document.getElementById('trip-wind').value.trim(),distance:parseFloat(document.getElementById('trip-distance').value)||null,notes:document.getElementById('trip-notes').value.trim(),passengers:[...editingPax]};
|
||
if(id){const ex=state.trips.find(x=>x.id===id);if(ex&&ex.track&&!track)data.track=ex.track;else if(track)data.track=track}else if(track)data.track=track;
|
||
if(id){const idx=state.trips.findIndex(x=>x.id===id);if(idx>=0)state.trips[idx]=data}else state.trips.unshift(data);
|
||
editingPax.forEach(p=>{if(!state.passengers.includes(p))state.passengers.push(p)});
|
||
await saveEditingMediaFor(tid,'trip');saveState();closeModal('trip-modal');await renderAll();toast('Lançado no diário');
|
||
}
|
||
async function deleteTrip(id){if(!confirm('Apagar travessia e mídias?'))return;state.trips=state.trips.filter(t=>t.id!==id);await deleteAllMediaFor(id);saveState();await renderAll();toast('Removido')}
|
||
|
||
const CATEGORIES={motor:'Motor',velas:'Velas & Cordames',casco:'Casco & Pintura',eletrica:'Elétrica',eletronica:'Eletrônica',seguranca:'Segurança',outros:'Outros'};
|
||
|
||
async function openMaintModal(id,fp){
|
||
document.getElementById('maint-id').value=id||'';
|
||
document.getElementById('maint-from-pending').value=fp||'';
|
||
document.getElementById('maint-modal-title').textContent=id?'Editar Reparo':'Novo Reparo';
|
||
editingMedia=[];
|
||
if(id){const m=state.maint.find(x=>x.id===id);if(!m)return;document.getElementById('maint-title').value=m.title||'';document.getElementById('maint-date').value=m.date||'';document.getElementById('maint-hours').value=m.hours??'';document.getElementById('maint-category').value=m.category||'motor';document.getElementById('maint-cost').value=m.cost??'';document.getElementById('maint-vendor').value=m.vendor||'';document.getElementById('maint-notes').value=m.notes||'';const md=await loadEntryMedia(id);editingMedia=md.map(x=>({...x,saved:true,parentType:'maint'}))}
|
||
else{['maint-title','maint-hours','maint-cost','maint-vendor','maint-notes'].forEach(k=>document.getElementById(k).value='');document.getElementById('maint-date').value=todayISO();document.getElementById('maint-category').value='motor';const last=currentEngineHours();if(last!==null)document.getElementById('maint-hours').value=last;if(fp){const p=state.pending.find(x=>x.id===fp);if(p){document.getElementById('maint-title').value=p.title||'';document.getElementById('maint-category').value=p.category||'motor';document.getElementById('maint-cost').value=p.estimatedCost??'';document.getElementById('maint-notes').value=p.notes||''}}}
|
||
await renderModalMedia('maint');openModal('maint-modal');
|
||
}
|
||
async function saveMaint(){
|
||
const id=document.getElementById('maint-id').value,fp=document.getElementById('maint-from-pending').value;
|
||
const t=document.getElementById('maint-title').value.trim();
|
||
if(!t){toast('Informe o serviço');return}
|
||
const mid=id||uid();
|
||
const data={id:mid,title:t,date:document.getElementById('maint-date').value,hours:parseFloat(document.getElementById('maint-hours').value)||null,category:document.getElementById('maint-category').value,cost:parseFloat(document.getElementById('maint-cost').value)||null,vendor:document.getElementById('maint-vendor').value.trim(),notes:document.getElementById('maint-notes').value.trim()};
|
||
if(id){const idx=state.maint.findIndex(x=>x.id===id);if(idx>=0)state.maint[idx]=data}else state.maint.unshift(data);
|
||
if(fp){const p=state.pending.find(x=>x.id===fp);if(p){p.done=true;p.completedMaintId=mid;p.completedAt=Date.now()}}
|
||
await saveEditingMediaFor(mid,'maint');saveState();closeModal('maint-modal');await renderAll();toast('Reparo lançado');
|
||
}
|
||
async function deleteMaint(id){if(!confirm('Apagar reparo e mídias?'))return;state.maint=state.maint.filter(m=>m.id!==id);await deleteAllMediaFor(id);saveState();await renderAll();toast('Removido')}
|
||
|
||
function openPendingModal(id){
|
||
document.getElementById('pending-id').value=id||'';
|
||
document.getElementById('pending-modal-title').textContent=id?'Editar Pendência':'Nova Pendência';
|
||
if(id){const p=state.pending.find(x=>x.id===id);if(!p)return;document.getElementById('pending-title').value=p.title||'';document.getElementById('pending-category').value=p.category||'motor';document.getElementById('pending-due-date').value=p.dueDate||'';document.getElementById('pending-due-hours').value=p.dueHours??'';document.getElementById('pending-est-cost').value=p.estimatedCost??'';document.getElementById('pending-notes').value=p.notes||'';document.querySelector(`input[name="pending-priority"][value="${p.priority||'normal'}"]`).checked=true}
|
||
else{['pending-title','pending-due-date','pending-due-hours','pending-est-cost','pending-notes'].forEach(k=>document.getElementById(k).value='');document.getElementById('pending-category').value='motor';document.querySelector('input[name="pending-priority"][value="normal"]').checked=true}
|
||
openModal('pending-modal');
|
||
}
|
||
function savePending(){
|
||
const id=document.getElementById('pending-id').value,t=document.getElementById('pending-title').value.trim();
|
||
if(!t){toast('Informe o que precisa fazer');return}
|
||
const data={id:id||uid(),title:t,category:document.getElementById('pending-category').value,dueDate:document.getElementById('pending-due-date').value||null,dueHours:parseFloat(document.getElementById('pending-due-hours').value)||null,estimatedCost:parseFloat(document.getElementById('pending-est-cost').value)||null,priority:document.querySelector('input[name="pending-priority"]:checked').value,notes:document.getElementById('pending-notes').value.trim(),done:false,createdAt:Date.now()};
|
||
if(id){const idx=state.pending.findIndex(x=>x.id===id);if(idx>=0)Object.assign(state.pending[idx],data)}else state.pending.unshift(data);
|
||
saveState();closeModal('pending-modal');renderAll();toast('Anotado');
|
||
}
|
||
function deletePending(id){if(!confirm('Apagar pendência?'))return;state.pending=state.pending.filter(p=>p.id!==id);saveState();renderAll();toast('Removido')}
|
||
function markPendingDone(id){openMaintModal(null,id)}
|
||
function filterPending(f){pendingFilter=f;document.querySelectorAll('.pending-toggle button').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));renderPending()}
|
||
function pendingStatus(p){if(p.done)return{kind:'done',label:'Feito'};const today=todayISO(),cur=currentEngineHours();let o=false,s=false;if(p.dueDate){if(p.dueDate<today)o=true;else if(daysBetween(today,p.dueDate)<=7)s=true}if(p.dueHours!=null&&cur!=null){if(cur>=p.dueHours)o=true;else if(p.dueHours-cur<=10)s=true}if(o)return{kind:'overdue',label:'Atrasado'};if(s)return{kind:'soon',label:'Em breve'};return{kind:'ok',label:'Em dia'}}
|
||
|
||
function currentEngineHours(){let max=null;state.trips.forEach(t=>{if(t.hoursEnd!=null&&(max===null||t.hoursEnd>max))max=t.hoursEnd;if(t.hoursStart!=null&&(max===null||t.hoursStart>max))max=t.hoursStart});state.maint.forEach(m=>{if(m.hours!=null&&(max===null||m.hours>max))max=m.hours});return max}
|
||
function totalEngineHoursLogged(){let total=0;state.trips.forEach(t=>{if(t.hoursStart!=null&&t.hoursEnd!=null&&t.hoursEnd>=t.hoursStart)total+=(t.hoursEnd-t.hoursStart)});return total}
|
||
|
||
|
||
async function renderTrips(){
|
||
const list=document.getElementById('trips-list');
|
||
if(state.trips.length===0){list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Diário em branco</div><div class="empty-text">Nenhuma travessia registrada. Lance a primeira para começar a história deste veleiro.</div></div>`;return}
|
||
const sorted=[...state.trips].sort((a,b)=>(b.dateStart||'').localeCompare(a.dateStart||''));
|
||
const allMedia=await dbAll();
|
||
const mp={};allMedia.forEach(m=>{if(!mp[m.parentId])mp[m.parentId]=[];mp[m.parentId].push(m)});
|
||
list.innerHTML=sorted.map(t=>{
|
||
const hd=(t.hoursStart!=null&&t.hoursEnd!=null)?(t.hoursEnd-t.hoursStart).toFixed(1):null;
|
||
const md=mp[t.id]||[];
|
||
const trackBlock=t.track?`<div class="entry-track">
|
||
<div class="entry-track-stats">
|
||
<div><span>milhas</span><strong>${metersToNM(t.track.distance).toFixed(2)}</strong></div>
|
||
<div><span>nós méd</span><strong>${msToKnots(t.track.avgSpeed||0).toFixed(1)}</strong></div>
|
||
<div><span>nós máx</span><strong>${msToKnots(t.track.maxSpeed).toFixed(1)}</strong></div>
|
||
</div>
|
||
<div style="display:flex;gap:5px"><button class="btn btn-track" onclick="openMapModal('${t.id}')">Ver carta</button><button class="btn btn-track" onclick="exportTripGPX('${t.id}')" style="background:transparent;color:var(--brass);border-color:var(--brass)">↓ GPX</button></div>
|
||
</div>`:'';
|
||
const fields=[];
|
||
if(t.hoursStart!=null||t.hoursEnd!=null){fields.push(`<dt>Motor</dt><dd><span class="num">${fmtHours(t.hoursStart)} → ${fmtHours(t.hoursEnd)}</span>${hd?` <span class="delta">(${hd}h)</span>`:''}</dd>`)}
|
||
if(t.distance!=null)fields.push(`<dt>Distância</dt><dd><span class="num">${t.distance}</span> mn</dd>`);
|
||
if(t.wind)fields.push(`<dt>Vento</dt><dd>${escapeHtml(t.wind)}</dd>`);
|
||
return `<div class="entry">
|
||
<div class="entry-head">
|
||
<div class="entry-meta">
|
||
<div class="entry-date">${fmtDateRange(t.dateStart,t.dateEnd)}</div>
|
||
<div class="entry-title">${escapeHtml(t.destination)||'sem destino'}</div>
|
||
</div>
|
||
<div class="entry-actions"><button class="icon-btn" onclick="openTripModal('${t.id}')" title="Editar">✎</button><button class="icon-btn del" onclick="deleteTrip('${t.id}')" title="Apagar">×</button></div>
|
||
</div>
|
||
${fields.length?`<div class="entry-body"><dl class="entry-grid">${fields.join('')}</dl>`:'<div class="entry-body">'}
|
||
${trackBlock}
|
||
${(t.passengers&&t.passengers.length)?`<div class="entry-passengers">${t.passengers.map(p=>`<span class="pax-pill">${escapeHtml(p)}</span>`).join('')}</div>`:''}
|
||
${t.notes?`<div class="entry-notes">${escapeHtml(t.notes)}</div>`:''}
|
||
${renderMediaThumbnails(md)}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderMediaThumbnails(media){
|
||
if(!media.length)return'';
|
||
return `<div class="media-grid">`+media.map(m=>{const u=getMediaUrl(m);if(m.kind==='photo')return`<div class="media-thumb" onclick="openViewer('${m.id}')"><img src="${u}" alt=""></div>`;if(m.kind==='video')return`<div class="media-thumb video" onclick="openViewer('${m.id}')"><video src="${u}" muted></video></div>`;return`<div class="media-thumb audio" onclick="openViewer('${m.id}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg></div>`}).join('')+`</div>`;
|
||
}
|
||
|
||
async function renderMaint(){
|
||
const list=document.getElementById('maint-list');
|
||
if(state.maint.length===0){list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Nenhum reparo registrado</div><div class="empty-text">O histórico de manutenção valoriza o barco e ajuda em revisões futuras.</div></div>`;return}
|
||
const sorted=[...state.maint].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
|
||
const allMedia=await dbAll();
|
||
const mp={};allMedia.forEach(m=>{if(!mp[m.parentId])mp[m.parentId]=[];mp[m.parentId].push(m)});
|
||
list.innerHTML=sorted.map(m=>{
|
||
const md=mp[m.id]||[];
|
||
const fields=[];
|
||
if(m.hours!=null)fields.push(`<dt>Horímetro</dt><dd><span class="num">${fmtHours(m.hours)}</span> h</dd>`);
|
||
if(m.cost!=null)fields.push(`<dt>Custo</dt><dd><span class="num">${fmtMoney(m.cost)}</span></dd>`);
|
||
if(m.vendor)fields.push(`<dt>Local</dt><dd>${escapeHtml(m.vendor)}</dd>`);
|
||
return `<div class="entry maint">
|
||
<div class="entry-head">
|
||
<div class="entry-meta">
|
||
<div class="entry-date">${fmtDate(m.date)}<span class="sep">·</span>${CATEGORIES[m.category]||m.category}</div>
|
||
<div class="entry-title">${escapeHtml(m.title)}</div>
|
||
</div>
|
||
<div class="entry-actions"><button class="icon-btn" onclick="openMaintModal('${m.id}')">✎</button><button class="icon-btn del" onclick="deleteMaint('${m.id}')">×</button></div>
|
||
</div>
|
||
<div class="entry-body">
|
||
${fields.length?`<dl class="entry-grid">${fields.join('')}</dl>`:''}
|
||
${m.notes?`<div class="entry-notes">${escapeHtml(m.notes)}</div>`:''}
|
||
${renderMediaThumbnails(md)}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderPending(){
|
||
const list=document.getElementById('pending-list');
|
||
let items=[...state.pending];
|
||
if(pendingFilter==='active')items=items.filter(p=>!p.done);
|
||
const po={high:0,normal:1,low:2},so={overdue:0,soon:1,ok:2,done:3};
|
||
items.sort((a,b)=>{const sa=so[pendingStatus(a).kind],sb=so[pendingStatus(b).kind];if(sa!==sb)return sa-sb;return po[a.priority||'normal']-po[b.priority||'normal']});
|
||
const aa=state.pending.filter(p=>!p.done&&['overdue','soon'].includes(pendingStatus(p).kind)).length;
|
||
const badge=document.getElementById('pending-badge');
|
||
if(aa>0){badge.textContent=aa;badge.style.display='inline-block'}else badge.style.display='none';
|
||
if(items.length===0){list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Tudo em ordem</div><div class="empty-text">Nenhuma manutenção pendente. Anote serviços previstos para não esquecer.</div></div>`;return}
|
||
list.innerHTML=items.map(p=>{
|
||
const st=pendingStatus(p),cur=currentEngineHours();
|
||
const hl=(p.dueHours!=null&&cur!=null)?(p.dueHours-cur):null;
|
||
let dt='';if(p.dueDate)dt+=`Até ${fmtDate(p.dueDate)}`;
|
||
if(p.dueHours!=null){if(dt)dt+=' · ';dt+=`${p.dueHours}h motor`;if(hl!=null)dt+=hl>0?` (faltam ${hl.toFixed(1)}h)`:` (passou em ${(-hl).toFixed(1)}h)`}
|
||
const pl=p.priority==='high'?'Alta':p.priority==='low'?'Baixa':'Normal';
|
||
const fields=[];
|
||
if(dt)fields.push(`<dt>Prazo</dt><dd>${dt}</dd>`);
|
||
if(p.estimatedCost!=null)fields.push(`<dt>Estimado</dt><dd><span class="num">${fmtMoney(p.estimatedCost)}</span></dd>`);
|
||
fields.push(`<dt>Prioridade</dt><dd>${pl}</dd>`);
|
||
return `<div class="entry pending ${st.kind==='overdue'?'overdue':''} ${p.done?'done':''}">
|
||
<div class="entry-head">
|
||
<div class="entry-meta">
|
||
<div class="entry-date">${CATEGORIES[p.category]||p.category}<span class="sep">·</span><span class="status-pill status-${st.kind}">${st.label}</span></div>
|
||
<div class="entry-title">${escapeHtml(p.title)}</div>
|
||
</div>
|
||
<div class="entry-actions">${!p.done?`<button class="icon-btn" onclick="markPendingDone('${p.id}')" title="Feito">✓</button>`:''}<button class="icon-btn" onclick="openPendingModal('${p.id}')">✎</button><button class="icon-btn del" onclick="deletePending('${p.id}')">×</button></div>
|
||
</div>
|
||
<div class="entry-body">
|
||
<dl class="entry-grid">${fields.join('')}</dl>
|
||
${p.notes?`<div class="entry-notes">${escapeHtml(p.notes)}</div>`:''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderOverview(){
|
||
const cur=currentEngineHours();
|
||
document.getElementById('stat-hours').textContent=cur!=null?fmtHours(cur):'—';
|
||
document.getElementById('stat-hours-sub').textContent=cur!=null?`${totalEngineHoursLogged().toFixed(1)} h navegadas`:'sem leituras';
|
||
document.getElementById('stat-trips').textContent=state.trips.length;
|
||
document.getElementById('stat-trips-sub').textContent=state.trips.length===1?'registrada':'registradas';
|
||
document.getElementById('stat-maint').textContent=state.maint.length;
|
||
const tc=state.maint.reduce((s,m)=>s+(m.cost||0),0);
|
||
document.getElementById('stat-maint-sub').textContent=tc?fmtMoney(tc):'no diário';
|
||
const ap=state.pending.filter(p=>!p.done);
|
||
document.getElementById('stat-pending').textContent=ap.length;
|
||
const et=ap.reduce((s,p)=>s+(p.estimatedCost||0),0);
|
||
document.getElementById('stat-pending-sub').textContent=et?`~${fmtMoney(et)}`:'a fazer';
|
||
// alerts
|
||
const aa=document.getElementById('alerts-area');
|
||
const overdue=state.pending.filter(p=>!p.done&&pendingStatus(p).kind==='overdue');
|
||
const soon=state.pending.filter(p=>!p.done&&pendingStatus(p).kind==='soon');
|
||
let h='';
|
||
const warnGlyph='<svg class="alert-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2 L22 20 H2 Z"/><line x1="12" y1="9" x2="12" y2="14"/><circle cx="12" cy="17.5" r="1" fill="currentColor"/></svg>';
|
||
const clockGlyph='<svg class="alert-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><path d="M12 7v5l3 2"/></svg>';
|
||
if(overdue.length)h+=`<div class="alert" onclick="goToPending()">${warnGlyph}<div class="alert-text"><strong>${overdue.length} ${overdue.length===1?'pendência atrasada':'pendências atrasadas'}</strong>${overdue.slice(0,2).map(p=>escapeHtml(p.title)).join(', ')}${overdue.length>2?'…':''}</div><div class="alert-arrow">→</div></div>`;
|
||
if(soon.length)h+=`<div class="alert warn" onclick="goToPending()">${clockGlyph}<div class="alert-text"><strong>${soon.length} ${soon.length===1?'pendência próxima':'pendências próximas'}</strong>${soon.slice(0,2).map(p=>escapeHtml(p.title)).join(', ')}${soon.length>2?'…':''}</div><div class="alert-arrow">→</div></div>`;
|
||
aa.innerHTML=h;
|
||
// recent
|
||
const recent=document.getElementById('recent-entries');
|
||
const all=[...state.trips.map(t=>({...t,_kind:'trip',_date:t.dateStart})),...state.maint.map(m=>({...m,_kind:'maint',_date:m.date}))].filter(x=>x._date).sort((a,b)=>b._date.localeCompare(a._date)).slice(0,4);
|
||
if(all.length===0){recent.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Bem-vindo a bordo</div><div class="empty-text">Comece registrando uma travessia ou um reparo. O diário fica salvo neste dispositivo.</div></div>`;return}
|
||
recent.innerHTML=`<div class="section-header"><span class="ornament">✦</span><h2>Últimos lançamentos</h2><span class="ornament">✦</span></div><div class="entries">${all.map(it=>it._kind==='trip'?`<div class="entry"><div class="entry-head"><div class="entry-meta"><div class="entry-date">${fmtDateRange(it.dateStart,it.dateEnd)}${it.track?'<span class="sep">·</span>com rastreio':''}</div><div class="entry-title">${escapeHtml(it.destination)||'sem destino'}</div></div></div></div>`:`<div class="entry maint"><div class="entry-head"><div class="entry-meta"><div class="entry-date">${fmtDate(it.date)}<span class="sep">·</span>${CATEGORIES[it.category]||''}${it.cost?'<span class="sep">·</span>'+fmtMoney(it.cost):''}</div><div class="entry-title">${escapeHtml(it.title)}</div></div></div></div>`).join('')}</div>`;
|
||
}
|
||
|
||
function goToPending(){document.querySelector('.tab[data-panel="pending"]').click()}
|
||
|
||
async function renderAll(){clearMediaCache();renderGPSBanner();renderAnchorBanner();renderOverview();await renderTrips();await renderMaint();renderPending()}
|
||
|
||
function openMapModal(tid){
|
||
const t=state.trips.find(x=>x.id===tid);
|
||
if(!t||!t.track){toast('Sem rastreio salvo');return}
|
||
openModal('map-modal');
|
||
setTimeout(()=>{
|
||
if(mapInstance){mapInstance.remove();mapInstance=null}
|
||
mapInstance=L.map('map-modal-content');
|
||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(mapInstance);
|
||
const ll=t.track.points.map(p=>[p.lat,p.lng]);
|
||
L.polyline(ll,{color:'#a07832',weight:4,opacity:.85}).addTo(mapInstance);
|
||
if(ll.length){L.marker(ll[0]).addTo(mapInstance).bindPopup('Saída');L.marker(ll[ll.length-1]).addTo(mapInstance).bindPopup('Chegada');mapInstance.fitBounds(ll,{padding:[20,20]})}
|
||
setTimeout(()=>mapInstance.invalidateSize(),100);
|
||
const dur=(t.track.endedAt-t.track.startedAt)/1000;
|
||
document.getElementById('map-modal-stats').innerHTML=`
|
||
<div class="map-stat"><span class="map-stat-num">${metersToNM(t.track.distance).toFixed(2)}</span><span class="map-stat-lbl">milhas</span></div>
|
||
<div class="map-stat"><span class="map-stat-num">${msToKnots(t.track.avgSpeed||t.track.distance/dur).toFixed(1)}</span><span class="map-stat-lbl">nós méd</span></div>
|
||
<div class="map-stat"><span class="map-stat-num">${msToKnots(t.track.maxSpeed).toFixed(1)}</span><span class="map-stat-lbl">nós máx</span></div>`;
|
||
},50);
|
||
}
|
||
function closeMapModal(){closeModal('map-modal');if(mapInstance){setTimeout(()=>{mapInstance.remove();mapInstance=null},300)}}
|
||
|
||
let currentViewerMediaId=null;
|
||
async function openViewer(id){const it=await dbGet(id);if(!it)return;currentViewerMediaId=id;const u=getMediaUrl(it);const c=document.getElementById('viewer-content');if(it.kind==='photo')c.innerHTML=`<img src="${u}" alt="">`;else if(it.kind==='video')c.innerHTML=`<video src="${u}" controls autoplay></video>`;else c.innerHTML=`<audio src="${u}" controls autoplay></audio>`;document.getElementById('viewer-modal').classList.add('show')}
|
||
function closeViewer(){const c=document.getElementById('viewer-content');c.querySelectorAll('video,audio').forEach(el=>el.pause());c.innerHTML='';document.getElementById('viewer-modal').classList.remove('show');currentViewerMediaId=null}
|
||
async function deleteCurrentMedia(){if(!currentViewerMediaId)return;if(!confirm('Apagar mídia?'))return;await dbDelete(currentViewerMediaId);closeViewer();await renderAll();toast('Apagada')}
|
||
async function downloadCurrentMedia(){if(!currentViewerMediaId)return;const it=await dbGet(currentViewerMediaId);if(!it)return;const ext=it.kind==='photo'?'jpg':it.kind==='video'?'mp4':'webm';const u=URL.createObjectURL(it.blob);const a=document.createElement('a');a.href=u;a.download=`midia-${it.id}.${ext}`;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000)}
|
||
|
||
function blobToBase64(b){return new Promise((r,j)=>{const f=new FileReader();f.onload=()=>r(f.result);f.onerror=j;f.readAsDataURL(b)})}
|
||
function base64ToBlob(d){const[m,b]=d.split(',');const mime=m.match(/:(.*?);/)[1];const bin=atob(b);const arr=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)arr[i]=bin.charCodeAt(i);return new Blob([arr],{type:mime})}
|
||
|
||
async function exportJSON(includeMedia){
|
||
const p=JSON.parse(JSON.stringify(state));
|
||
if(includeMedia){toast('Empacotando mídias...');const all=await dbAll();p._media=[];for(const m of all){const d=await blobToBase64(m.blob);p._media.push({id:m.id,kind:m.kind,mime:m.mime,parentId:m.parentId,parentType:m.parentType,createdAt:m.createdAt,data:d})}}
|
||
const blob=new Blob([JSON.stringify(p)],{type:'application/json'});
|
||
triggerDownload(blob,`diario-${slug(state.boat.name)}-${todayISO()}${includeMedia?'-completo':''}.json`);
|
||
toast('Backup gerado');
|
||
}
|
||
|
||
function exportCSV(kind){
|
||
let rows,name;
|
||
if(kind==='trips'){rows=[['Data Saida','Data Chegada','Destino','Hor Saida','Hor Chegada','Horas','Distancia','Vento','Tripulacao','GPS Dist mn','GPS Vmax kn','GPS Vmed kn','Notas']];state.trips.forEach(t=>{const d=(t.hoursStart!=null&&t.hoursEnd!=null)?(t.hoursEnd-t.hoursStart).toFixed(1):'';const td=t.track?metersToNM(t.track.distance).toFixed(2):'';const tm=t.track?msToKnots(t.track.maxSpeed).toFixed(1):'';const ta=t.track?msToKnots(t.track.avgSpeed||0).toFixed(1):'';rows.push([t.dateStart||'',t.dateEnd||'',t.destination||'',t.hoursStart??'',t.hoursEnd??'',d,t.distance??'',t.wind||'',(t.passengers||[]).join('; '),td,tm,ta,(t.notes||'').replace(/\n/g,' ')])});name=`travessias-${slug(state.boat.name)}-${todayISO()}.csv`}
|
||
else if(kind==='maint'){rows=[['Data','Servico','Categoria','Horimetro','Custo','Local','Detalhes']];state.maint.forEach(m=>rows.push([m.date||'',m.title||'',CATEGORIES[m.category]||'',m.hours??'',m.cost??'',m.vendor||'',(m.notes||'').replace(/\n/g,' ')]));name=`reparos-${slug(state.boat.name)}-${todayISO()}.csv`}
|
||
else{rows=[['Status','Servico','Categoria','Prazo','Hor Alvo','Custo Est','Prioridade','Detalhes']];state.pending.forEach(p=>rows.push([p.done?'Feito':pendingStatus(p).label,p.title,CATEGORIES[p.category]||'',p.dueDate||'',p.dueHours??'',p.estimatedCost??'',p.priority||'normal',(p.notes||'').replace(/\n/g,' ')]));name=`pendencias-${slug(state.boat.name)}-${todayISO()}.csv`}
|
||
const csv=rows.map(r=>r.map(c=>{const s=String(c);return /[",;\n]/.test(s)?`"${s.replace(/"/g,'""')}"`:s}).join(';')).join('\n');
|
||
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'});
|
||
triggerDownload(blob,name);toast('CSV gerado');
|
||
}
|
||
|
||
function triggerDownload(blob,fn){const u=URL.createObjectURL(blob);const a=document.createElement('a');a.href=u;a.download=fn;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1000)}
|
||
function slug(s){return(s||'veleiro').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')}
|
||
|
||
async function shareData(){
|
||
const cur=currentEngineHours();
|
||
const summary=`Diário de Bordo — ${state.boat.name}\nHorímetro: ${cur!=null?fmtHours(cur)+'h':'—'}\n${state.trips.length} travessias · ${state.maint.length} reparos\n${state.pending.filter(p=>!p.done).length} pendentes`;
|
||
const json=JSON.stringify(state);
|
||
const blob=new Blob([json],{type:'application/json'});
|
||
const file=new File([blob],`diario-${slug(state.boat.name)}-${todayISO()}.json`,{type:'application/json'});
|
||
if(navigator.canShare&&navigator.canShare({files:[file]})){try{await navigator.share({title:`Diário — ${state.boat.name}`,text:summary,files:[file]});return}catch(e){}}
|
||
else if(navigator.share){try{await navigator.share({title:`Diário — ${state.boat.name}`,text:summary});return}catch(e){}}
|
||
exportJSON(false);toast('Compartilhe o arquivo baixado');
|
||
}
|
||
|
||
async function importJSON(event){
|
||
const file=event.target.files[0];if(!file)return;
|
||
const reader=new FileReader();
|
||
reader.onload=async(e)=>{try{const data=JSON.parse(e.target.result);if(!data.trips&&!data.maint&&!data.pending&&!data.boat)throw new Error('Formato inválido');if(!confirm('Importar substituirá os dados atuais. Continuar?'))return;Object.assign(state,{boat:data.boat||state.boat,trips:data.trips||[],maint:data.maint||[],pending:data.pending||[],passengers:data.passengers||[]});const all=await dbAll();for(const m of all)await dbDelete(m.id);if(data._media&&Array.isArray(data._media)){for(const m of data._media){const blob=base64ToBlob(m.data);await dbPut({id:m.id,kind:m.kind,blob,mime:m.mime,parentId:m.parentId,parentType:m.parentType,createdAt:m.createdAt})}}saveState();bindHeader();await renderAll();toast('Dados importados')}catch(err){alert('Arquivo inválido: '+err.message)}};
|
||
reader.readAsText(file);event.target.value='';
|
||
}
|
||
|
||
async function resetAll(){if(!confirm('Apagar TODO o diário?'))return;if(!confirm('Tem certeza? Recomendo backup antes.'))return;localStorage.removeItem(STORAGE_KEY);localStorage.removeItem(TRACKING_KEY);const all=await dbAll();for(const m of all)await dbDelete(m.id);state.boat={name:'Shivao',model:''};state.trips=[];state.maint=[];state.pending=[];state.passengers=[];bindHeader();await renderAll();toast('Diário apagado')}
|
||
|
||
async function updateStorageInfo(){
|
||
try{
|
||
const all=await dbAll();let tb=0;all.forEach(m=>tb+=m.blob.size);
|
||
const mb=(tb/1024/1024).toFixed(2);
|
||
let extra='';
|
||
if(navigator.storage&&navigator.storage.estimate){const e=await navigator.storage.estimate();const u=(e.usage/1024/1024).toFixed(1);const q=(e.quota/1024/1024).toFixed(0);extra=` · TOTAL: ${u} de ${q} MB`}
|
||
document.getElementById('storage-info').textContent=`${all.length} mídias · ${mb} MB${extra}`;
|
||
}catch(e){document.getElementById('storage-info').textContent='—'}
|
||
}
|
||
|
||
(async()=>{
|
||
await openDB();loadState();bindHeader();await renderAll();
|
||
document.getElementById('fab').style.display='none';
|
||
loadTrackingState();
|
||
loadAnchorState();
|
||
initBattery();
|
||
// tenta auto-fetch do tempo após pequeno delay
|
||
setTimeout(maybeAutoFetchWeather,3000);
|
||
})();
|
||
document.addEventListener('visibilitychange',async()=>{if(document.visibilityState==='visible'){if(tracking.active&&!tracking.wakeLock)await requestWakeLock();if(anchorWatch.active&&!anchorWatch.wakeLock)await requestAnchorWakeLock()}});
|
||
|
||
// ============ ANCHOR WATCH ============
|
||
const anchorWatch={active:false,watchId:null,startedAt:null,anchorPos:null,swingPos:null,radius:50,currentDist:0,maxDist:0,wakeLock:null,alarmFired:false,alarmCount:0,autoRecenter:false,recentPositions:[]};
|
||
let anchorMap=null,anchorMarker=null,swingMarker=null,anchorBoatMarker=null,anchorCircle=null,anchorLine=null,swingAnchorLine=null,anchorTimer=null,autoRecenterTimer=null;
|
||
let pendingAnchorPos=null;
|
||
|
||
async function requestAnchorWakeLock(){try{if('wakeLock' in navigator){anchorWatch.wakeLock=await navigator.wakeLock.request('screen');anchorWatch.wakeLock.addEventListener('release',()=>{anchorWatch.wakeLock=null;if(anchorWatch.active)setTimeout(()=>{if(anchorWatch.active&&!anchorWatch.wakeLock)requestAnchorWakeLock()},1000)})}}catch(e){console.warn('wake lock anchor:',e)}}
|
||
async function releaseAnchorWakeLock(){try{if(anchorWatch.wakeLock){await anchorWatch.wakeLock.release();anchorWatch.wakeLock=null}}catch(e){}}
|
||
|
||
function loadAnchorState(){try{const raw=localStorage.getItem(ANCHOR_KEY);if(raw){const a=JSON.parse(raw);if(a.active&&confirm('Há uma vigia de fundeio em andamento. Continuar?')){Object.assign(anchorWatch,a);anchorWatch.watchId=null;anchorWatch.wakeLock=null;startAnchorGPS();anchorTimer=setInterval(updateAnchorUI,1000);requestAnchorWakeLock();renderAnchorBanner()}else{localStorage.removeItem(ANCHOR_KEY)}}}catch(e){}}
|
||
function saveAnchorState(){try{const{watchId,wakeLock,...r}=anchorWatch;localStorage.setItem(ANCHOR_KEY,JSON.stringify(r))}catch(e){}}
|
||
|
||
async function openAnchorSetup(){
|
||
if(tracking.active){toast('Pare o rastreio antes de fundear');return}
|
||
if(!navigator.geolocation){toast('Sem GPS no dispositivo');return}
|
||
document.getElementById('anchor-setup-status').textContent='Aguardando posição do GPS…';
|
||
document.getElementById('anchor-coord').textContent='—';
|
||
document.getElementById('anchor-confirm-btn').disabled=true;
|
||
document.getElementById('anchor-radius').value=50;
|
||
updateRadiusLabel(50);
|
||
pendingAnchorPos=null;
|
||
updateContactsSummary();
|
||
openModal('anchor-setup-modal');
|
||
// tentar obter posição
|
||
navigator.geolocation.getCurrentPosition(
|
||
pos=>{
|
||
pendingAnchorPos={lat:pos.coords.latitude,lng:pos.coords.longitude,acc:pos.coords.accuracy};
|
||
document.getElementById('anchor-setup-status').textContent=`Precisão do GPS: ±${Math.round(pos.coords.accuracy)}m`;
|
||
document.getElementById('anchor-coord').textContent=`${pendingAnchorPos.lat.toFixed(6)}, ${pendingAnchorPos.lng.toFixed(6)}`;
|
||
document.getElementById('anchor-confirm-btn').disabled=false;
|
||
},
|
||
err=>{document.getElementById('anchor-setup-status').textContent='Erro: '+err.message},
|
||
{enableHighAccuracy:true,timeout:20000,maximumAge:0}
|
||
);
|
||
}
|
||
|
||
function updateRadiusLabel(v){document.getElementById('anchor-radius-val').textContent=`${v} m`}
|
||
function updateContactsSummary(){
|
||
const el=document.getElementById('anchor-contacts-summary');
|
||
if(!el)return;
|
||
const n=state.contacts.length;
|
||
const wh=state.webhooks||{};
|
||
const whCount=(wh.telegram?.token?1:0)+(wh.discord?.url?1:0)+(wh.generic?.url?1:0);
|
||
let parts=[];
|
||
if(n>0)parts.push(`${n} contato${n>1?'s':''}`);
|
||
if(whCount>0)parts.push(`${whCount} webhook${whCount>1?'s':''} (auto)`);
|
||
if(parts.length===0){el.textContent='Nenhum contato ou webhook configurado';el.style.color='var(--storm)'}
|
||
else{el.textContent=parts.join(' · ');el.style.color='var(--algae)'}
|
||
}
|
||
|
||
async function confirmAnchor(){
|
||
if(!pendingAnchorPos){toast('Aguarde o GPS');return}
|
||
anchorWatch.active=true;
|
||
anchorWatch.startedAt=Date.now();
|
||
anchorWatch.anchorPos=pendingAnchorPos;
|
||
anchorWatch.swingPos={lat:pendingAnchorPos.lat,lng:pendingAnchorPos.lng};
|
||
anchorWatch.radius=parseInt(document.getElementById('anchor-radius').value);
|
||
anchorWatch.currentDist=0;
|
||
anchorWatch.maxDist=0;
|
||
anchorWatch.alarmFired=false;
|
||
anchorWatch.alarmCount=0;
|
||
anchorWatch.autoRecenter=false;
|
||
anchorWatch.recentPositions=[];
|
||
saveAnchorState();
|
||
await requestAnchorWakeLock();
|
||
startAnchorGPS();
|
||
anchorTimer=setInterval(updateAnchorUI,1000);
|
||
// notify cloud + start heartbeat
|
||
cloudAnchorStart();
|
||
startHeartbeat();
|
||
closeModal('anchor-setup-modal');
|
||
openAnchorWatchModal();
|
||
renderAnchorBanner();
|
||
toast('Vigia ativada · âncora marcada');
|
||
}
|
||
|
||
function startAnchorGPS(){
|
||
let retry=0;
|
||
function tryStart(){
|
||
anchorWatch.watchId=navigator.geolocation.watchPosition(
|
||
pos=>{retry=0;onAnchorGPSUpdate(pos)},
|
||
err=>{
|
||
if(err.code===err.PERMISSION_DENIED){toast('GPS sem permissão — vigia COMPROMETIDA');return}
|
||
retry++;
|
||
const delay=Math.min(1000*Math.pow(2,retry-1),30000);
|
||
toast(`Vigia GPS perdido (#${retry}) — retentando em ${delay/1000}s`);
|
||
if(anchorWatch.watchId)navigator.geolocation.clearWatch(anchorWatch.watchId);
|
||
setTimeout(()=>{if(anchorWatch.active)tryStart()},delay);
|
||
},
|
||
batteryGPSOptions()
|
||
);
|
||
}
|
||
tryStart();
|
||
}
|
||
|
||
function onAnchorGPSUpdate(pos){
|
||
if(!anchorWatch.active||!anchorWatch.anchorPos)return;
|
||
if(pos.coords.accuracy>50)return;
|
||
const cur={lat:pos.coords.latitude,lng:pos.coords.longitude};
|
||
const center=anchorWatch.swingPos||anchorWatch.anchorPos;
|
||
const d=haversine(center,cur);
|
||
anchorWatch.currentDist=d;
|
||
anchorWatch.lastPos=cur;
|
||
if(d>anchorWatch.maxDist)anchorWatch.maxDist=d;
|
||
// mantém últimos 30 pontos para auto-recenter
|
||
anchorWatch.recentPositions.push({lat:cur.lat,lng:cur.lng,ts:Date.now()});
|
||
if(anchorWatch.recentPositions.length>30)anchorWatch.recentPositions.shift();
|
||
saveAnchorState();
|
||
if(d>anchorWatch.radius&&!anchorWatch.alarmFired){
|
||
anchorWatch.alarmFired=true;
|
||
anchorWatch.alarmCount++;
|
||
triggerAnchorAlarm();
|
||
}else if(d<=anchorWatch.radius*0.85&&anchorWatch.alarmFired){
|
||
anchorWatch.alarmFired=false;
|
||
}
|
||
updateAnchorUI();
|
||
updateAnchorMap(cur);
|
||
}
|
||
|
||
function recenterSwing(){
|
||
if(!anchorWatch.active||!anchorWatch.lastPos)return;
|
||
if(!confirm('Definir a posição atual do barco como novo centro de giro?\n\nIsso é útil quando o barco já se acomodou na corrente/vento e não está exatamente sobre a âncora.'))return;
|
||
anchorWatch.swingPos={lat:anchorWatch.lastPos.lat,lng:anchorWatch.lastPos.lng};
|
||
saveAnchorState();
|
||
drawAnchorOnMap();
|
||
updateAnchorUI();
|
||
toast('Centro recentrado');
|
||
}
|
||
|
||
function toggleAutoRecenter(){
|
||
anchorWatch.autoRecenter=!anchorWatch.autoRecenter;
|
||
const btn=document.getElementById('auto-recenter-btn');
|
||
if(btn)btn.textContent='Auto: '+(anchorWatch.autoRecenter?'on':'off');
|
||
if(anchorWatch.autoRecenter){
|
||
autoRecenterTimer=setInterval(autoRecenterCheck,10*60*1000); // a cada 10min
|
||
toast('Auto-recentro ativado · ajusta a cada 10 min');
|
||
}else{
|
||
if(autoRecenterTimer){clearInterval(autoRecenterTimer);autoRecenterTimer=null}
|
||
toast('Auto-recentro desativado');
|
||
}
|
||
saveAnchorState();
|
||
}
|
||
|
||
function autoRecenterCheck(){
|
||
if(!anchorWatch.active||!anchorWatch.autoRecenter)return;
|
||
const pts=anchorWatch.recentPositions;
|
||
if(pts.length<10)return;
|
||
// média dos últimos 10 pontos
|
||
const recent=pts.slice(-10);
|
||
const avgLat=recent.reduce((s,p)=>s+p.lat,0)/recent.length;
|
||
const avgLng=recent.reduce((s,p)=>s+p.lng,0)/recent.length;
|
||
// só recentra se a diferença for significativa (>5m do swingPos atual) E moderada (<radius*0.5)
|
||
const cur=anchorWatch.swingPos;
|
||
const shift=haversine(cur,{lat:avgLat,lng:avgLng});
|
||
if(shift<5||shift>anchorWatch.radius*0.5)return;
|
||
anchorWatch.swingPos={lat:avgLat,lng:avgLng};
|
||
saveAnchorState();
|
||
drawAnchorOnMap();
|
||
toast(`Auto-recentro · ${shift.toFixed(1)}m`);
|
||
}
|
||
|
||
function updateAnchorUI(){
|
||
if(!anchorWatch.active)return;
|
||
const d=anchorWatch.currentDist;
|
||
document.getElementById('aw-distance').innerHTML=`${Math.round(d)}<span class="anchor-stat-unit">m</span>`;
|
||
document.getElementById('aw-radius').innerHTML=`${anchorWatch.radius}<span class="anchor-stat-unit">m</span>`;
|
||
document.getElementById('aw-duration').textContent=fmtDuration((Date.now()-anchorWatch.startedAt)/1000);
|
||
// status color
|
||
const distEl=document.getElementById('aw-distance');
|
||
distEl.classList.remove('warn','alarm');
|
||
if(d>anchorWatch.radius)distEl.classList.add('alarm');
|
||
else if(d>anchorWatch.radius*0.8)distEl.classList.add('warn');
|
||
// banner update
|
||
const bd=document.getElementById('ab-distance');
|
||
if(bd)bd.textContent=Math.round(d)+'m';
|
||
const bs=document.getElementById('ab-status');
|
||
if(bs){
|
||
if(d>anchorWatch.radius){bs.textContent='ALERTA · derivando';bs.className='anchor-status warn'}
|
||
else if(d>anchorWatch.radius*0.8){bs.textContent='Atenção · próximo do limite';bs.className='anchor-status warn'}
|
||
else{bs.textContent='Em segurança';bs.className='anchor-status safe'}
|
||
}
|
||
// also update alarm distance display if visible
|
||
const ad=document.getElementById('alarm-distance');
|
||
if(ad&&document.getElementById('alarm-screen').classList.contains('show')){
|
||
ad.innerHTML=`${Math.round(d)}<span style="font-size:24px">m</span>`;
|
||
}
|
||
}
|
||
|
||
function openAnchorWatchModal(){
|
||
openModal('anchor-watch-modal');
|
||
setTimeout(()=>{
|
||
if(!anchorMap){anchorMap=L.map('anchor-map').setView([anchorWatch.anchorPos.lat,anchorWatch.anchorPos.lng],17);L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(anchorMap)}
|
||
setTimeout(()=>anchorMap.invalidateSize(),100);
|
||
drawAnchorOnMap();
|
||
document.getElementById('aw-radius-slider').value=anchorWatch.radius;
|
||
document.getElementById('aw-radius-slider-val').textContent=anchorWatch.radius+' m';
|
||
updateAnchorUI();
|
||
},50);
|
||
}
|
||
|
||
function drawAnchorOnMap(){
|
||
if(!anchorMap||!anchorWatch.anchorPos)return;
|
||
const a=[anchorWatch.anchorPos.lat,anchorWatch.anchorPos.lng];
|
||
const sp=anchorWatch.swingPos||anchorWatch.anchorPos;
|
||
const s=[sp.lat,sp.lng];
|
||
// âncora (ponto fixo onde foi largada)
|
||
if(!anchorMarker){
|
||
const anchorIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#a07832" stroke="#0e2a3d" stroke-width="1"><circle cx="12" cy="5" r="2.5"/><path d="M11 7v15h2V7z"/><path d="M5 12a7 7 0 0 0 14 0" stroke="#0e2a3d" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M8 10h8" stroke="#0e2a3d" stroke-width="2" stroke-linecap="round"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
|
||
anchorMarker=L.marker(a,{icon:anchorIcon,zIndexOffset:1000,title:'Âncora'}).addTo(anchorMap);
|
||
}else anchorMarker.setLatLng(a);
|
||
// centro de giro (swing pos) - pequeno marcador se diferente da âncora
|
||
const isDifferent=haversine(anchorWatch.anchorPos,sp)>3;
|
||
if(isDifferent){
|
||
if(!swingMarker){
|
||
const swingIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="14" height="14" fill="#1f5b76" stroke="#fff" stroke-width="1.5"><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>',iconSize:[14,14],iconAnchor:[7,7],className:''});
|
||
swingMarker=L.marker(s,{icon:swingIcon,zIndexOffset:900,title:'Centro de giro'}).addTo(anchorMap);
|
||
}else swingMarker.setLatLng(s);
|
||
if(swingAnchorLine)swingAnchorLine.remove();
|
||
swingAnchorLine=L.polyline([a,s],{color:'#a07832',weight:1.5,opacity:.5,dashArray:'2,4'}).addTo(anchorMap);
|
||
}else{
|
||
if(swingMarker){swingMarker.remove();swingMarker=null}
|
||
if(swingAnchorLine){swingAnchorLine.remove();swingAnchorLine=null}
|
||
}
|
||
// círculo de raio centrado em swingPos
|
||
if(anchorCircle)anchorCircle.remove();
|
||
anchorCircle=L.circle(s,{radius:anchorWatch.radius,color:'#a07832',weight:2,fillColor:'#a07832',fillOpacity:.08,dashArray:'5,5'}).addTo(anchorMap);
|
||
// posição do barco
|
||
if(anchorWatch.lastPos){
|
||
const c=[anchorWatch.lastPos.lat,anchorWatch.lastPos.lng];
|
||
if(!anchorBoatMarker){
|
||
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="22" height="22" fill="#1f5b76" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[22,22],iconAnchor:[11,11],className:''});
|
||
anchorBoatMarker=L.marker(c,{icon:boatIcon}).addTo(anchorMap);
|
||
}else anchorBoatMarker.setLatLng(c);
|
||
if(anchorLine)anchorLine.remove();
|
||
anchorLine=L.polyline([s,c],{color:'#1f5b76',weight:2,opacity:.6,dashArray:'3,3'}).addTo(anchorMap);
|
||
}
|
||
}
|
||
|
||
function updateAnchorMap(cur){
|
||
if(!anchorMap)return;
|
||
drawAnchorOnMap();
|
||
}
|
||
|
||
function adjustAnchorRadius(v){
|
||
anchorWatch.radius=parseInt(v);
|
||
document.getElementById('aw-radius-slider-val').textContent=v+' m';
|
||
saveAnchorState();
|
||
drawAnchorOnMap();
|
||
updateAnchorUI();
|
||
// re-evaluate alarm state
|
||
if(anchorWatch.currentDist<=anchorWatch.radius*0.85)anchorWatch.alarmFired=false;
|
||
}
|
||
|
||
function minimizeAnchor(){closeModal('anchor-watch-modal')}
|
||
|
||
async function stopAnchorWatch(){
|
||
if(!confirm('Encerrar vigia de fundeio?'))return;
|
||
if(anchorWatch.watchId!=null)navigator.geolocation.clearWatch(anchorWatch.watchId);
|
||
await releaseAnchorWakeLock();
|
||
clearInterval(anchorTimer);
|
||
if(autoRecenterTimer){clearInterval(autoRecenterTimer);autoRecenterTimer=null}
|
||
stopHeartbeat();
|
||
cloudAnchorStop();
|
||
// salvar no histórico
|
||
const entry={
|
||
id:uid(),
|
||
startedAt:anchorWatch.startedAt,
|
||
endedAt:Date.now(),
|
||
anchorPos:anchorWatch.anchorPos,
|
||
swingPos:anchorWatch.swingPos,
|
||
radius:anchorWatch.radius,
|
||
maxDist:anchorWatch.maxDist,
|
||
alarmCount:anchorWatch.alarmCount,
|
||
lastPos:anchorWatch.lastPos
|
||
};
|
||
state.anchorHistory.unshift(entry);
|
||
if(state.anchorHistory.length>50)state.anchorHistory=state.anchorHistory.slice(0,50);
|
||
saveState();
|
||
anchorWatch.active=false;
|
||
anchorWatch.alarmFired=false;
|
||
localStorage.removeItem(ANCHOR_KEY);
|
||
closeModal('anchor-watch-modal');
|
||
if(anchorMap){setTimeout(()=>{anchorMap.remove();anchorMap=null;anchorMarker=null;swingMarker=null;anchorBoatMarker=null;anchorCircle=null;anchorLine=null;swingAnchorLine=null},300)}
|
||
stopAlarm();
|
||
renderAnchorBanner();
|
||
toast('Vigia encerrada · salva no histórico');
|
||
}
|
||
|
||
function renderAnchorBanner(){
|
||
const el=document.getElementById('anchor-banner');
|
||
if(!el)return;
|
||
if(anchorWatch.active){
|
||
const d=Math.round(anchorWatch.currentDist);
|
||
const status=d>anchorWatch.radius?'ALERTA · derivando':d>anchorWatch.radius*0.8?'Atenção · próximo do limite':'Em segurança';
|
||
const cls=d>anchorWatch.radius*0.8?'warn':'safe';
|
||
el.innerHTML=`<div class="anchor-card active">
|
||
<div class="gps-head" style="position:relative"><h3 style="color:var(--brass-bright)">Vigia de fundeio</h3></div>
|
||
<div class="anchor-status ${cls}" id="ab-status" style="margin-bottom:14px;position:relative">${status}</div>
|
||
<div class="gps-stats" style="position:relative">
|
||
<div class="gps-stat"><div class="gps-stat-label">Distância</div><div class="gps-stat-value"><span id="ab-distance">${d}m</span></div></div>
|
||
<div class="gps-stat"><div class="gps-stat-label">Raio</div><div class="gps-stat-value">${anchorWatch.radius}<span class="gps-stat-unit">m</span></div></div>
|
||
<div class="gps-stat"><div class="gps-stat-label">Tempo</div><div class="gps-stat-value">${fmtDuration((Date.now()-anchorWatch.startedAt)/1000)}</div></div>
|
||
</div>
|
||
<div class="gps-actions" style="position:relative">
|
||
<button class="btn" onclick="openAnchorWatchModal()" style="background:transparent;color:var(--brass-bright);border-color:var(--brass)">Ver carta</button>
|
||
<button class="btn" onclick="stopAnchorWatch()" style="background:var(--storm);color:#fff;border-color:var(--storm)">Levantar âncora</button>
|
||
</div>
|
||
</div>`;
|
||
}else{
|
||
const histCount=state.anchorHistory.length;
|
||
el.innerHTML=`<div class="anchor-card">
|
||
<div class="gps-head"><h3 style="font-family:var(--f-display);font-style:italic;font-weight:500;font-size:18px;margin:0;color:var(--ink-deep)">Vigia de fundeio</h3></div>
|
||
<div class="gps-sub" style="margin-bottom:14px">Marca a posição da âncora · alarme se o Shivao derivar</div>
|
||
<div class="gps-actions"><button class="btn btn-block btn-big" style="background:var(--algae);color:#fff;border-color:var(--algae)" onclick="openAnchorSetup()">Fundear</button></div>
|
||
${histCount>0?`<div style="text-align:center;margin-top:10px"><button class="btn btn-sm" onclick="openAnchorHistory()" style="background:transparent;border:none;color:var(--brass);font-family:var(--f-mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;padding:4px;text-decoration:underline">Ver histórico (${histCount})</button></div>`:''}
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// ============ ALARM ============
|
||
let alarmCtx=null,alarmOscillators=[],alarmInterval=null,vibrationInterval=null,alarmWakeLock=null;
|
||
|
||
async function triggerAnchorAlarm(){
|
||
// wake lock for screen
|
||
try{if('wakeLock' in navigator)alarmWakeLock=await navigator.wakeLock.request('screen')}catch(e){}
|
||
playAlarmSound();
|
||
startVibration();
|
||
showAlarmScreen();
|
||
// dispara webhooks automáticos (Telegram, Discord, genérico) - sem precisar tocar
|
||
dispatchWebhooks(buildAlarmText()).then(r=>{
|
||
if(r.sent.length)console.log('webhooks ok:',r.sent);
|
||
if(r.failed.length)console.warn('webhooks falhas:',r.failed);
|
||
});
|
||
// dispara também no servidor para notificar todos os contatos (se cloud configurada)
|
||
cloudAnchorAlarm();
|
||
}
|
||
|
||
function playAlarmSound(){
|
||
try{
|
||
if(!alarmCtx)alarmCtx=new (window.AudioContext||window.webkitAudioContext)();
|
||
if(alarmCtx.state==='suspended')alarmCtx.resume();
|
||
stopAlarmSound(); // clear any existing
|
||
let toggle=false;
|
||
const playTone=(freq)=>{
|
||
const osc=alarmCtx.createOscillator();
|
||
const gain=alarmCtx.createGain();
|
||
osc.type='square';
|
||
osc.frequency.value=freq;
|
||
gain.gain.value=0;
|
||
gain.gain.setValueAtTime(0,alarmCtx.currentTime);
|
||
gain.gain.linearRampToValueAtTime(.45,alarmCtx.currentTime+.02);
|
||
gain.gain.linearRampToValueAtTime(.45,alarmCtx.currentTime+.32);
|
||
gain.gain.linearRampToValueAtTime(0,alarmCtx.currentTime+.36);
|
||
osc.connect(gain);gain.connect(alarmCtx.destination);
|
||
osc.start();osc.stop(alarmCtx.currentTime+.4);
|
||
alarmOscillators.push(osc);
|
||
};
|
||
playTone(900);
|
||
alarmInterval=setInterval(()=>{toggle=!toggle;playTone(toggle?900:1200)},420);
|
||
}catch(e){console.error('alarm sound:',e)}
|
||
}
|
||
|
||
function stopAlarmSound(){
|
||
if(alarmInterval){clearInterval(alarmInterval);alarmInterval=null}
|
||
alarmOscillators.forEach(o=>{try{o.stop()}catch(e){}});
|
||
alarmOscillators=[];
|
||
}
|
||
|
||
function startVibration(){
|
||
if(!('vibrate' in navigator))return;
|
||
navigator.vibrate([800,300,400,300,800,500]);
|
||
vibrationInterval=setInterval(()=>navigator.vibrate([800,300,400,300,800,500]),3100);
|
||
}
|
||
function stopVibration(){
|
||
if(vibrationInterval){clearInterval(vibrationInterval);vibrationInterval=null}
|
||
if('vibrate' in navigator)navigator.vibrate(0);
|
||
}
|
||
|
||
function stopAlarm(){
|
||
stopAlarmSound();
|
||
stopVibration();
|
||
document.getElementById('alarm-screen').classList.remove('show');
|
||
if(alarmWakeLock){try{alarmWakeLock.release()}catch(e){}alarmWakeLock=null}
|
||
}
|
||
|
||
function showAlarmScreen(){
|
||
const el=document.getElementById('alarm-screen');
|
||
document.getElementById('alarm-title').textContent=`${state.boat.name} derivando!`;
|
||
// build action buttons for each contact
|
||
renderAlarmActions();
|
||
el.classList.add('show');
|
||
}
|
||
|
||
function dismissAlarm(){
|
||
if(!confirm('Cancelar o alarme? O Shivao ainda está fora do raio.\n\nIsto silencia o som mas NÃO encerra a vigia.'))return;
|
||
stopAlarm();
|
||
}
|
||
|
||
function testAlarmSound(){
|
||
playAlarmSound();
|
||
startVibration();
|
||
setTimeout(()=>{stopAlarmSound();stopVibration()},2500);
|
||
toast('Alarme testado · 2.5s');
|
||
}
|
||
|
||
function renderAlarmActions(){
|
||
const el=document.getElementById('alarm-actions');
|
||
if(!state.contacts.length){
|
||
el.innerHTML=`<div style="text-align:center;color:rgba(255,255,255,.85);font-family:var(--f-display);font-style:italic;font-size:15px;padding:14px">Nenhum contato cadastrado.<br>Configure em Vigia → Contatos.</div>`;
|
||
return;
|
||
}
|
||
const waSvg='<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.5 14.4c-.3-.1-1.7-.8-2-.9-.3-.1-.5-.1-.7.1l-1 1.2c-.2.2-.4.2-.7.1-.9-.4-1.7-.9-2.5-1.7-.7-.7-1.2-1.5-1.7-2.4-.1-.3-.1-.5.1-.7l.7-.7c.2-.2.2-.4.3-.6 0-.2 0-.4-.1-.6L9 6.4c-.1-.3-.4-.5-.7-.5h-1c-.4 0-.7.1-.9.4-.5.5-.8 1.2-.8 2 0 1.7.6 3.3 1.7 4.7.7 1.1 1.5 2.1 2.5 3 1 .8 2 1.5 3.2 2 1.6.7 2.9 1 3.6 1 .8 0 1.5-.3 2-.8.3-.3.4-.6.4-1v-.9c0-.4-.2-.5-.5-.5z"/><path d="M19 5C17.1 3.1 14.6 2 12 2 9.4 2 6.9 3.1 5 5 3.1 6.9 2 9.4 2 12c0 1.7.5 3.4 1.4 4.9L2 22l5.2-1.4c1.5.8 3.1 1.2 4.8 1.2 5.5 0 10-4.5 10-10 0-2.6-1.1-5.1-3-6.8zm-7 15.1c-1.5 0-3-.4-4.3-1.2l-.3-.2-3.1.8.8-3-.2-.3c-2.4-3.9-1.2-9.1 2.7-11.5C12 1.5 17 2.7 19.4 6.7c2.4 3.9 1.2 9.1-2.7 11.5-1.4.8-2.9 1.2-4.7.9z" opacity=".5"/></svg>';
|
||
const smsSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12c0 4-4 7-9 7-1.5 0-3-.3-4.3-.9L3 19l1-3.5C3.4 14.4 3 13.2 3 12c0-4 4-7 9-7s9 3 9 7z"/></svg>';
|
||
const mailSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 7l9 6 9-6"/></svg>';
|
||
const buttons=[];
|
||
for(const c of state.contacts){
|
||
const msg=buildAlarmMessage(c);
|
||
if(c.channels?.includes('whatsapp')&&c.phone)buttons.push(`<button class="alarm-btn wa" onclick="sendWhatsApp('${c.phone}',\`${msg.replace(/`/g,'\\`')}\`)">${waSvg} WhatsApp · ${escapeHtml(c.name)}</button>`);
|
||
if(c.channels?.includes('sms')&&c.phone)buttons.push(`<button class="alarm-btn sms" onclick="sendSMS('${c.phone}',\`${msg.replace(/`/g,'\\`')}\`)">${smsSvg} SMS · ${escapeHtml(c.name)}</button>`);
|
||
if(c.channels?.includes('email')&&c.email)buttons.push(`<button class="alarm-btn mail" onclick="sendEmail('${c.email}',\`${msg.replace(/`/g,'\\`')}\`)">${mailSvg} E-mail · ${escapeHtml(c.name)}</button>`);
|
||
}
|
||
el.innerHTML=buttons.join('');
|
||
}
|
||
|
||
function buildAlarmMessage(contact){
|
||
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
|
||
const lat=pos.lat.toFixed(6);
|
||
const lng=pos.lng.toFixed(6);
|
||
const now=new Date();
|
||
const hora=`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||
const dist=Math.round(anchorWatch.currentDist);
|
||
const tpl=state.alarmTemplate||'';
|
||
return tpl.replace(/{LAT}/g,lat).replace(/{LNG}/g,lng).replace(/{HORA}/g,hora).replace(/{DIST}/g,dist).replace(/{NOME}/g,state.boat.name||'Veleiro');
|
||
}
|
||
|
||
function sendWhatsApp(phone,msg){
|
||
const cleanPhone=phone.replace(/\D/g,'');
|
||
const url=`https://wa.me/${cleanPhone}?text=${encodeURIComponent(msg)}`;
|
||
window.open(url,'_blank');
|
||
}
|
||
function sendSMS(phone,msg){
|
||
const url=`sms:+${phone.replace(/\D/g,'')}?body=${encodeURIComponent(msg)}`;
|
||
window.location.href=url;
|
||
}
|
||
function sendEmail(email,msg){
|
||
const subject=`ALERTA: ${state.boat.name||'Veleiro'} derivando`;
|
||
const url=`mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(msg)}`;
|
||
window.location.href=url;
|
||
}
|
||
|
||
// ============ CONTACTS ============
|
||
function openContactsModal(){
|
||
renderContactsList();
|
||
document.getElementById('alarm-message-template').value=state.alarmTemplate||'';
|
||
['contact-name','contact-phone','contact-email'].forEach(k=>document.getElementById(k).value='');
|
||
document.getElementById('contact-ch-wa').checked=true;
|
||
document.getElementById('contact-ch-sms').checked=true;
|
||
document.getElementById('contact-ch-mail').checked=false;
|
||
// carrega webhooks
|
||
document.getElementById('wh-tg-token').value=state.webhooks?.telegram?.token||'';
|
||
document.getElementById('wh-tg-chats').value=state.webhooks?.telegram?.chatIds||'';
|
||
document.getElementById('wh-discord').value=state.webhooks?.discord?.url||'';
|
||
document.getElementById('wh-generic').value=state.webhooks?.generic?.url||'';
|
||
document.getElementById('webhook-test-result').innerHTML='';
|
||
openModal('contacts-modal');
|
||
}
|
||
|
||
function renderContactsList(){
|
||
const el=document.getElementById('contacts-list');
|
||
if(!state.contacts.length){el.innerHTML='<div style="text-align:center;padding:18px;color:var(--sepia);font-family:var(--f-display);font-style:italic;font-size:14px">Nenhum contato cadastrado</div>';return}
|
||
el.innerHTML=state.contacts.map(c=>{
|
||
const channels=(c.channels||[]).map(ch=>`<span class="channel-pill on">${ch==='whatsapp'?'WhatsApp':ch==='sms'?'SMS':'E-mail'}</span>`).join('');
|
||
return `<div class="contact-card">
|
||
<div class="contact-info">
|
||
<div class="contact-name">${escapeHtml(c.name)}</div>
|
||
<div class="contact-meta">${c.phone?'+'+escapeHtml(c.phone):''}${c.phone&&c.email?' · ':''}${c.email?escapeHtml(c.email):''}</div>
|
||
<div class="contact-channels">${channels}</div>
|
||
</div>
|
||
<button class="icon-btn del" onclick="removeContact('${c.id}')">×</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function addContact(){
|
||
const name=document.getElementById('contact-name').value.trim();
|
||
const phone=document.getElementById('contact-phone').value.replace(/\D/g,'');
|
||
const email=document.getElementById('contact-email').value.trim();
|
||
if(!name){toast('Informe o nome');return}
|
||
if(!phone&&!email){toast('Informe telefone ou e-mail');return}
|
||
const channels=[];
|
||
if(document.getElementById('contact-ch-wa').checked&&phone)channels.push('whatsapp');
|
||
if(document.getElementById('contact-ch-sms').checked&&phone)channels.push('sms');
|
||
if(document.getElementById('contact-ch-mail').checked&&email)channels.push('email');
|
||
if(!channels.length){toast('Escolha ao menos um canal');return}
|
||
state.contacts.push({id:uid(),name,phone,email,channels});
|
||
saveState();
|
||
renderContactsList();
|
||
['contact-name','contact-phone','contact-email'].forEach(k=>document.getElementById(k).value='');
|
||
toast('Contato adicionado');
|
||
}
|
||
|
||
function removeContact(id){
|
||
if(!confirm('Remover este contato?'))return;
|
||
state.contacts=state.contacts.filter(c=>c.id!==id);
|
||
saveState();
|
||
renderContactsList();
|
||
updateContactsSummary();
|
||
}
|
||
|
||
function saveContactsConfig(){
|
||
state.alarmTemplate=document.getElementById('alarm-message-template').value;
|
||
state.webhooks={
|
||
telegram:{token:document.getElementById('wh-tg-token').value.trim(),chatIds:document.getElementById('wh-tg-chats').value.trim()},
|
||
discord:{url:document.getElementById('wh-discord').value.trim()},
|
||
generic:{url:document.getElementById('wh-generic').value.trim()}
|
||
};
|
||
saveState();
|
||
closeModal('contacts-modal');
|
||
updateContactsSummary();
|
||
toast('Configuração salva');
|
||
}
|
||
|
||
// ============ CLOUD SYNC ============
|
||
let heartbeatInterval=null;
|
||
let cloudAutoSyncTimeout=null;
|
||
|
||
function cloudConfigured(){return state.cloud&&state.cloud.url&&state.cloud.token}
|
||
function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
|
||
|
||
async function cloudFetch(path,opts={}){
|
||
if(!cloudConfigured())throw new Error('Nuvem não configurada');
|
||
const r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${state.cloud.token}`,'Content-Type':'application/json',...(opts.headers||{})}});
|
||
if(!r.ok)throw new Error(`HTTP ${r.status}`);
|
||
return r;
|
||
}
|
||
|
||
function bindCloudInputs(){
|
||
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
|
||
if(!u||!t)return;
|
||
u.value=state.cloud?.url||'';
|
||
t.value=state.cloud?.token||'';
|
||
u.addEventListener('change',()=>{state.cloud.url=u.value.trim();saveState();renderCloudStatus()});
|
||
t.addEventListener('change',()=>{state.cloud.token=t.value.trim();saveState();renderCloudStatus()});
|
||
}
|
||
|
||
function renderCloudStatus(){
|
||
const el=document.getElementById('cloud-status');
|
||
if(!el)return;
|
||
if(cloudConfigured()){
|
||
const ls=state.cloud.lastSync?`última sync: ${new Date(state.cloud.lastSync).toLocaleString('pt-BR')}`:'aguardando sincronização';
|
||
el.textContent=`Conectado · ${ls}`;
|
||
el.style.color='var(--algae)';
|
||
}else{
|
||
el.textContent='Não conectado · use seu próprio servidor (Coolify/VPS) para sync automático e alarmes remotos.';
|
||
el.style.color='var(--sepia)';
|
||
}
|
||
}
|
||
|
||
async function testCloudConnection(){
|
||
try{
|
||
if(!cloudConfigured()){toast('Preencha servidor e token');return}
|
||
toast('Testando...');
|
||
const r=await cloudFetch('/api/info');
|
||
const info=await r.json();
|
||
document.getElementById('cloud-info').innerHTML=`Servidor OK · canais ativos: <strong>${info.channels.join(', ')||'(nenhum!)'}</strong> · timeout heartbeat: ${info.heartbeatTimeoutSec}s`;
|
||
toast('Conexão OK');
|
||
renderCloudStatus();
|
||
}catch(e){toast('Falhou: '+e.message);document.getElementById('cloud-info').innerHTML=`<span style="color:var(--storm)">Erro: ${escapeHtml(e.message)}</span>`}
|
||
}
|
||
|
||
async function cloudPushAll(){
|
||
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
|
||
try{
|
||
toast('Enviando dados...');
|
||
const{cloud,...dataNoCloud}=state;
|
||
await cloudFetch('/api/data',{method:'POST',body:JSON.stringify({data:dataNoCloud})});
|
||
// upload de mídias
|
||
const allMedia=await dbAll();
|
||
const remote=await cloudFetch('/api/media/list');
|
||
const remoteList=await remote.json();
|
||
const remoteIds=new Set(remoteList.map(m=>m.id));
|
||
let uploaded=0;
|
||
for(const m of allMedia){
|
||
if(remoteIds.has(m.id))continue;
|
||
const fd=new FormData();
|
||
fd.append('file',m.blob,`${m.id}.bin`);
|
||
fd.append('id',m.id);
|
||
fd.append('parent_id',m.parentId||'');
|
||
fd.append('parent_type',m.parentType||'');
|
||
fd.append('kind',m.kind);
|
||
fd.append('created_at',String(m.createdAt||Date.now()));
|
||
await fetch(cloudUrl('/api/media'),{method:'POST',headers:{'Authorization':`Bearer ${state.cloud.token}`},body:fd});
|
||
uploaded++;
|
||
}
|
||
state.cloud.lastSync=Date.now();
|
||
saveState();
|
||
renderCloudStatus();
|
||
toast(`Enviado · ${uploaded} mídias novas`);
|
||
}catch(e){toast('Erro: '+e.message)}
|
||
}
|
||
|
||
async function cloudPullAll(){
|
||
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
|
||
if(!confirm('Baixar substituirá os dados locais. Continuar?'))return;
|
||
try{
|
||
toast('Baixando dados...');
|
||
const r=await cloudFetch('/api/data');
|
||
const{data}=await r.json();
|
||
if(!data){toast('Nuvem vazia');return}
|
||
const cloudKeep=state.cloud;
|
||
Object.assign(state,data);
|
||
state.cloud=cloudKeep;
|
||
state.cloud.lastSync=Date.now();
|
||
saveState();bindHeader();
|
||
// baixar mídias faltantes
|
||
const remote=await cloudFetch('/api/media/list');
|
||
const remoteList=await remote.json();
|
||
const localAll=await dbAll();
|
||
const localIds=new Set(localAll.map(m=>m.id));
|
||
let downloaded=0;
|
||
for(const rm of remoteList){
|
||
if(localIds.has(rm.id))continue;
|
||
const f=await fetch(cloudUrl('/api/media/'+rm.id),{headers:{'Authorization':`Bearer ${state.cloud.token}`}});
|
||
if(!f.ok)continue;
|
||
const blob=await f.blob();
|
||
await dbPut({id:rm.id,kind:rm.kind,blob,mime:rm.mime,parentId:rm.parent_id,parentType:rm.parent_type,createdAt:rm.created_at});
|
||
downloaded++;
|
||
}
|
||
await renderAll();
|
||
renderCloudStatus();
|
||
toast(`Baixado · ${downloaded} mídias novas`);
|
||
}catch(e){toast('Erro: '+e.message)}
|
||
}
|
||
|
||
async function cloudTestAlarm(){
|
||
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
|
||
try{
|
||
toast('Disparando teste...');
|
||
const r=await cloudFetch('/api/test',{method:'POST'});
|
||
const res=await r.json();
|
||
document.getElementById('cloud-info').innerHTML=`Teste enviado · canais: <strong>${res.sent.join(', ')||'(nenhum!)'}</strong>${res.failed.length?`<br><span style="color:var(--storm)">falhas: ${res.failed.map(f=>f.channel).join(', ')}</span>`:''}`;
|
||
toast('Teste enviado');
|
||
}catch(e){toast('Erro: '+e.message)}
|
||
}
|
||
|
||
function cloudDisconnect(){
|
||
if(!confirm('Desconectar da nuvem? Os dados locais ficam preservados.'))return;
|
||
state.cloud={url:'',token:'',lastSync:0};
|
||
saveState();
|
||
document.getElementById('cloud-url').value='';
|
||
document.getElementById('cloud-token').value='';
|
||
document.getElementById('cloud-info').innerHTML='';
|
||
renderCloudStatus();
|
||
toast('Desconectado');
|
||
}
|
||
|
||
// ---- Anchor watch sync with cloud ----
|
||
async function cloudAnchorStart(){
|
||
if(!cloudConfigured())return;
|
||
try{
|
||
await cloudFetch('/api/anchor/start',{method:'POST',body:JSON.stringify({boat_name:state.boat.name,anchor_lat:anchorWatch.anchorPos.lat,anchor_lng:anchorWatch.anchorPos.lng,radius:anchorWatch.radius})});
|
||
}catch(e){console.warn('cloud anchor start',e)}
|
||
}
|
||
|
||
async function cloudAnchorStop(){
|
||
if(!cloudConfigured())return;
|
||
try{await cloudFetch('/api/anchor/stop',{method:'POST',body:'{}'})}catch(e){}
|
||
}
|
||
|
||
async function cloudAnchorAlarm(){
|
||
if(!cloudConfigured())return;
|
||
try{
|
||
const pos=anchorWatch.lastPos||anchorWatch.anchorPos;
|
||
const r=await cloudFetch('/api/anchor/alarm',{method:'POST',body:JSON.stringify({boat_name:state.boat.name,lat:pos.lat,lng:pos.lng,distance:anchorWatch.currentDist,radius:anchorWatch.radius,reason:'drift'})});
|
||
const res=await r.json();
|
||
console.log('cloud alarm fired:',res);
|
||
toast(`Servidor avisou: ${res.sent.join(', ')||'(canais offline)'}`);
|
||
}catch(e){console.warn('cloud alarm',e);toast('Falha ao avisar servidor — alarme local ativo')}
|
||
}
|
||
|
||
function startHeartbeat(){
|
||
if(heartbeatInterval)return;
|
||
// primeiro imediato, depois a cada 30s
|
||
sendHeartbeat();
|
||
heartbeatInterval=setInterval(sendHeartbeat,30000);
|
||
}
|
||
function stopHeartbeat(){if(heartbeatInterval){clearInterval(heartbeatInterval);heartbeatInterval=null}}
|
||
async function sendHeartbeat(){
|
||
if(!cloudConfigured()||!anchorWatch.active)return;
|
||
try{
|
||
const pos=anchorWatch.lastPos||anchorWatch.anchorPos;
|
||
if(!pos)return;
|
||
await cloudFetch('/api/anchor/heartbeat',{method:'POST',body:JSON.stringify({lat:pos.lat,lng:pos.lng,distance:anchorWatch.currentDist})});
|
||
}catch(e){console.warn('heartbeat falhou',e.message)}
|
||
}
|
||
|
||
// auto-sync on changes (debounced)
|
||
const _origSaveState=saveState;
|
||
saveState=function(){_origSaveState();if(cloudConfigured()){clearTimeout(cloudAutoSyncTimeout);cloudAutoSyncTimeout=setTimeout(()=>{cloudPushDataOnly()},5000)}};
|
||
|
||
async function cloudPushDataOnly(){
|
||
if(!cloudConfigured())return;
|
||
try{
|
||
const{cloud,...dataNoCloud}=state;
|
||
await cloudFetch('/api/data',{method:'POST',body:JSON.stringify({data:dataNoCloud})});
|
||
state.cloud.lastSync=Date.now();
|
||
_origSaveState();
|
||
renderCloudStatus();
|
||
}catch(e){console.warn('auto-sync',e.message)}
|
||
}
|
||
|
||
// ============ WEBHOOKS (envio automático sem cliques) ============
|
||
function buildAlarmText(){
|
||
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
|
||
const lat=pos.lat.toFixed(6),lng=pos.lng.toFixed(6);
|
||
const now=new Date();
|
||
const hora=`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||
const dist=Math.round(anchorWatch.currentDist);
|
||
const tpl=state.alarmTemplate||'';
|
||
return tpl.replace(/{LAT}/g,lat).replace(/{LNG}/g,lng).replace(/{HORA}/g,hora).replace(/{DIST}/g,dist).replace(/{NOME}/g,state.boat.name||'Veleiro');
|
||
}
|
||
|
||
async function dispatchWebhooks(text){
|
||
const tasks=[];
|
||
const wh=state.webhooks||{};
|
||
// Telegram
|
||
if(wh.telegram?.token&&wh.telegram?.chatIds){
|
||
const chats=wh.telegram.chatIds.split(',').map(s=>s.trim()).filter(Boolean);
|
||
for(const chatId of chats){
|
||
tasks.push(fetch(`https://api.telegram.org/bot${wh.telegram.token}/sendMessage`,{
|
||
method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({chat_id:chatId,text,disable_notification:false})
|
||
}).then(r=>r.ok?{ok:'telegram '+chatId}:{err:'telegram '+chatId+' '+r.status}).catch(e=>({err:'telegram '+e.message})));
|
||
}
|
||
}
|
||
// Discord
|
||
if(wh.discord?.url){
|
||
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
|
||
const lat=pos.lat.toFixed(6),lng=pos.lng.toFixed(6);
|
||
const dist=Math.round(anchorWatch.currentDist);
|
||
const payload={
|
||
username:state.boat.name||'Shivao',
|
||
embeds:[{
|
||
title:'🚨 ALERTA DE FUNDEIO',
|
||
description:text,
|
||
color:0x8c3434,
|
||
fields:[
|
||
{name:'Distância da âncora',value:`${dist} m / ${anchorWatch.radius} m`,inline:true},
|
||
{name:'Posição',value:`${lat}, ${lng}`,inline:true},
|
||
{name:'Mapa',value:`[Abrir](https://maps.google.com/?q=${lat},${lng})`,inline:true}
|
||
],
|
||
timestamp:new Date().toISOString()
|
||
}]
|
||
};
|
||
tasks.push(fetch(wh.discord.url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?{ok:'discord'}:{err:'discord '+r.status}).catch(e=>({err:'discord '+e.message})));
|
||
}
|
||
// Generic
|
||
if(wh.generic?.url){
|
||
const pos=anchorWatch.lastPos||anchorWatch.anchorPos||{lat:0,lng:0};
|
||
const payload={
|
||
boat:state.boat.name||'Shivao',
|
||
lat:pos.lat,lng:pos.lng,
|
||
distance:Math.round(anchorWatch.currentDist),
|
||
radius:anchorWatch.radius,
|
||
ts:Date.now(),
|
||
message:text,
|
||
mapsUrl:`https://maps.google.com/?q=${pos.lat},${pos.lng}`
|
||
};
|
||
tasks.push(fetch(wh.generic.url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?{ok:'webhook'}:{err:'webhook '+r.status}).catch(e=>({err:'webhook '+e.message})));
|
||
}
|
||
if(!tasks.length)return{sent:[],failed:[]};
|
||
const results=await Promise.all(tasks);
|
||
const sent=results.filter(r=>r.ok).map(r=>r.ok);
|
||
const failed=results.filter(r=>r.err).map(r=>r.err);
|
||
return{sent,failed};
|
||
}
|
||
|
||
async function testWebhooks(){
|
||
const text=`✅ Teste — ${state.boat.name||'Veleiro'} · sistema operacional · ${new Date().toLocaleString('pt-BR')}`;
|
||
// Salva config dos webhooks ANTES do teste (para usar valores atuais sem precisar salvar contatos)
|
||
const tg={token:document.getElementById('wh-tg-token').value.trim(),chatIds:document.getElementById('wh-tg-chats').value.trim()};
|
||
const dc={url:document.getElementById('wh-discord').value.trim()};
|
||
const gn={url:document.getElementById('wh-generic').value.trim()};
|
||
const oldWebhooks=state.webhooks;
|
||
state.webhooks={telegram:tg,discord:dc,generic:gn};
|
||
const r=await dispatchWebhooks(text);
|
||
state.webhooks=oldWebhooks; // restaura caso usuário cancele
|
||
const el=document.getElementById('webhook-test-result');
|
||
if(r.sent.length===0&&r.failed.length===0){el.innerHTML='<span style="color:var(--storm)">Nenhum webhook configurado</span>';return}
|
||
el.innerHTML=`Enviados: <strong>${r.sent.join(', ')||'(nenhum)'}</strong>${r.failed.length?`<br><span style="color:var(--storm)">Falhas: ${r.failed.join(', ')}</span>`:''}`;
|
||
toast(r.sent.length?'Teste enviado':'Falhou');
|
||
}
|
||
|
||
// ============ ANCHOR HISTORY ============
|
||
function openAnchorHistory(){
|
||
const list=document.getElementById('anchor-history-body');
|
||
if(state.anchorHistory.length===0){
|
||
list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Sem fundeios anteriores</div><div class="empty-text">Quando levantar âncora, o fundeio fica registrado aqui.</div></div>`;
|
||
}else{
|
||
list.innerHTML=state.anchorHistory.map(h=>{
|
||
const dur=fmtDuration((h.endedAt-h.startedAt)/1000);
|
||
const date=new Date(h.startedAt).toLocaleString('pt-BR',{day:'2-digit',month:'short',year:'2-digit',hour:'2-digit',minute:'2-digit'});
|
||
const maxKn=h.maxDist?h.maxDist.toFixed(0):'0';
|
||
const alarms=h.alarmCount||0;
|
||
return `<div class="entry">
|
||
<div class="entry-head">
|
||
<div class="entry-meta">
|
||
<div class="entry-date">${escapeHtml(date)}</div>
|
||
<div class="entry-title">Fundeio · ${dur}</div>
|
||
</div>
|
||
<div class="entry-actions"><button class="icon-btn" onclick="openAnchorHistoryMap('${h.id}')" title="Ver mapa">⚐</button><button class="icon-btn del" onclick="deleteAnchorHistory('${h.id}')">×</button></div>
|
||
</div>
|
||
<div class="entry-body">
|
||
<dl class="entry-grid">
|
||
<dt>Posição</dt><dd><span class="num">${h.anchorPos.lat.toFixed(5)}, ${h.anchorPos.lng.toFixed(5)}</span></dd>
|
||
<dt>Raio</dt><dd><span class="num">${h.radius}</span> m</dd>
|
||
<dt>Desvio máx.</dt><dd><span class="num">${maxKn}</span> m ${alarms?`<span style="color:var(--storm);font-family:var(--f-mono);font-size:10px;letter-spacing:.15em;text-transform:uppercase;margin-left:6px">· ${alarms} alarme${alarms>1?'s':''}</span>`:''}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
openModal('anchor-history-modal');
|
||
}
|
||
|
||
let historyMap=null;
|
||
function openAnchorHistoryMap(id){
|
||
const h=state.anchorHistory.find(x=>x.id===id);
|
||
if(!h)return;
|
||
document.getElementById('ahm-title').textContent='Fundeio · '+new Date(h.startedAt).toLocaleDateString('pt-BR');
|
||
openModal('anchor-history-map-modal');
|
||
setTimeout(()=>{
|
||
if(historyMap){historyMap.remove();historyMap=null}
|
||
historyMap=L.map('anchor-history-map').setView([h.anchorPos.lat,h.anchorPos.lng],17);
|
||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(historyMap);
|
||
const a=[h.anchorPos.lat,h.anchorPos.lng];
|
||
const sp=h.swingPos||h.anchorPos;
|
||
const s=[sp.lat,sp.lng];
|
||
const anchorIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#a07832" stroke="#0e2a3d" stroke-width="1"><circle cx="12" cy="5" r="2.5"/><path d="M11 7v15h2V7z"/><path d="M5 12a7 7 0 0 0 14 0" stroke="#0e2a3d" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M8 10h8" stroke="#0e2a3d" stroke-width="2" stroke-linecap="round"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
|
||
L.marker(a,{icon:anchorIcon,title:'Âncora'}).addTo(historyMap);
|
||
L.circle(s,{radius:h.radius,color:'#a07832',weight:2,fillColor:'#a07832',fillOpacity:.08,dashArray:'5,5'}).addTo(historyMap);
|
||
if(haversine(h.anchorPos,sp)>3){
|
||
const swingIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="14" height="14" fill="#1f5b76" stroke="#fff" stroke-width="1.5"><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>',iconSize:[14,14],iconAnchor:[7,7],className:''});
|
||
L.marker(s,{icon:swingIcon,title:'Centro de giro'}).addTo(historyMap);
|
||
L.polyline([a,s],{color:'#a07832',weight:1.5,opacity:.5,dashArray:'2,4'}).addTo(historyMap);
|
||
}
|
||
if(h.lastPos){
|
||
const c=[h.lastPos.lat,h.lastPos.lng];
|
||
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="22" height="22" fill="#1f5b76" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[22,22],iconAnchor:[11,11],className:''});
|
||
L.marker(c,{icon:boatIcon,title:'Última posição'}).addTo(historyMap);
|
||
}
|
||
setTimeout(()=>historyMap.invalidateSize(),100);
|
||
const dur=fmtDuration((h.endedAt-h.startedAt)/1000);
|
||
document.getElementById('ahm-stats').innerHTML=`
|
||
<div class="map-stat"><span class="map-stat-num">${h.maxDist.toFixed(0)}</span><span class="map-stat-lbl">m máx</span></div>
|
||
<div class="map-stat"><span class="map-stat-num">${h.radius}</span><span class="map-stat-lbl">m raio</span></div>
|
||
<div class="map-stat"><span class="map-stat-num">${h.alarmCount||0}</span><span class="map-stat-lbl">alarmes</span></div>`;
|
||
},50);
|
||
}
|
||
|
||
function closeAnchorHistoryMap(){closeModal('anchor-history-map-modal');if(historyMap){setTimeout(()=>{historyMap.remove();historyMap=null},300)}}
|
||
|
||
function deleteAnchorHistory(id){
|
||
if(!confirm('Apagar este registro de fundeio?'))return;
|
||
state.anchorHistory=state.anchorHistory.filter(h=>h.id!==id);
|
||
saveState();
|
||
openAnchorHistory();
|
||
renderAnchorBanner();
|
||
}
|
||
|
||
// ============ WEATHER (Open-Meteo) ============
|
||
const weather={lastFetch:0,lastPos:null,data:null,fetching:false};
|
||
|
||
async function fetchWeather(lat,lng){
|
||
if(state.weatherCfg?.windyKey)return fetchWeatherWindy(lat,lng);
|
||
return fetchWeatherOpenMeteo(lat,lng);
|
||
}
|
||
|
||
async function fetchWeatherWindy(lat,lng){
|
||
if(weather.fetching)return;
|
||
weather.fetching=true;
|
||
const key=state.weatherCfg.windyKey;
|
||
const model=state.weatherCfg.model||'gfs';
|
||
try{
|
||
// Modelo principal: vento, temp, pressão, nuvens, precipitação
|
||
const reqMain=fetch('https://api.windy.com/api/point-forecast/v2',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({lat,lon:lng,model,parameters:['wind','windGust','temp','pressure','lclouds','mclouds','hclouds','rh','past3hprecip'],levels:['surface'],key})
|
||
}).then(async r=>{
|
||
if(!r.ok)throw new Error('Windy '+r.status+': '+(await r.text()).slice(0,100));
|
||
return r.json();
|
||
});
|
||
// Modelo de ondas em paralelo
|
||
const reqWaves=fetch('https://api.windy.com/api/point-forecast/v2',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({lat,lon:lng,model:'gfsWave',parameters:['waves','swell1','wwind'],levels:['surface'],key})
|
||
}).then(r=>r.ok?r.json():null).catch(()=>null);
|
||
const[main,waves]=await Promise.all([reqMain,reqWaves]);
|
||
weather.data={provider:'windy',main,waves,model,fetchedAt:Date.now(),lat,lng};
|
||
weather.lastFetch=Date.now();
|
||
weather.lastPos={lat,lng};
|
||
renderWeather();
|
||
}catch(e){
|
||
console.warn('windy fail, falling back:',e.message);
|
||
toast('Windy: '+e.message.slice(0,40)+' · usando fallback');
|
||
weather.fetching=false;
|
||
return fetchWeatherOpenMeteo(lat,lng);
|
||
}
|
||
weather.fetching=false;
|
||
}
|
||
|
||
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`;
|
||
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};
|
||
renderWeather();
|
||
}catch(e){console.warn('weather',e.message)}
|
||
weather.fetching=false;
|
||
}
|
||
|
||
// Helpers de conversão de unidades
|
||
function uvToSpeedDir(u,v){
|
||
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
|
||
const speedMs=Math.sqrt(u*u+v*v);
|
||
// Direção meteorológica: de onde o vento vem
|
||
const dir=(270-Math.atan2(v,u)*180/Math.PI+360)%360;
|
||
return{speedMs,dir};
|
||
}
|
||
function kelvinToC(k){return k-273.15}
|
||
function paToHpa(p){return p/100}
|
||
|
||
const WMO_CODES={0:'Céu limpo',1:'Maioria limpo',2:'Parcial nublado',3:'Nublado',45:'Neblina',48:'Neblina densa',51:'Garoa leve',53:'Garoa',55:'Garoa intensa',61:'Chuva leve',63:'Chuva',65:'Chuva forte',71:'Neve leve',73:'Neve',75:'Neve forte',77:'Granizo',80:'Pancadas',81:'Pancadas fortes',82:'Pancadas violentas',95:'Trovoada',96:'Trovoada+granizo',99:'Trovoada forte'};
|
||
const WMO_ICONS={0:'☀',1:'🌤',2:'⛅',3:'☁',45:'🌫',48:'🌫',51:'🌦',53:'🌦',55:'🌧',61:'🌧',63:'🌧',65:'⛈',71:'🌨',73:'🌨',75:'❄',77:'❄',80:'🌧',81:'⛈',82:'⛈',95:'⛈',96:'⛈',99:'⛈'};
|
||
|
||
function windCardinal(deg){
|
||
if(deg==null)return'';
|
||
const dirs=['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
|
||
return dirs[Math.round(deg/22.5)%16];
|
||
}
|
||
|
||
function renderWeather(){
|
||
const el=document.getElementById('weather-widget');
|
||
if(!el)return;
|
||
const d=weather.data;
|
||
if(!d){el.innerHTML='';return}
|
||
if(d.provider==='windy')return renderWindyWeather(el,d);
|
||
return renderOpenMeteoWeather(el,d);
|
||
}
|
||
|
||
function renderWindyWeather(el,d){
|
||
const m=d.main;
|
||
if(!m||!m.ts||!m.ts.length){el.innerHTML='';return}
|
||
const u=m['wind_u-surface']?.[0]??0;
|
||
const v=m['wind_v-surface']?.[0]??0;
|
||
const{speedMs,dir}=uvToSpeedDir(u,v);
|
||
const speedKn=speedMs*1.94384;
|
||
const dirCard=windCardinal(dir);
|
||
const gustMs=m['gust-surface']?.[0]??0;
|
||
const gustKn=gustMs*1.94384;
|
||
const tempK=m['temp-surface']?.[0];
|
||
const tempC=tempK?kelvinToC(tempK):null;
|
||
const pressPa=m['pressure-surface']?.[0];
|
||
const pressHpa=pressPa?paToHpa(pressPa):null;
|
||
const lc=m['lclouds-surface']?.[0]??0;
|
||
const mc=m['mclouds-surface']?.[0]??0;
|
||
const hc=m['hclouds-surface']?.[0]??0;
|
||
const totalClouds=Math.max(lc,mc,hc);
|
||
const cloudIcon=totalClouds<20?'☀':totalClouds<50?'🌤':totalClouds<80?'⛅':'☁';
|
||
const precip=m['past3hprecip-surface']?.[0]??0;
|
||
const precipIcon=precip>2?'🌧':precip>0.2?'🌦':'';
|
||
const desc=precipIcon?(precip>2?'Chuva':'Garoa'):(totalClouds<20?'Céu limpo':totalClouds<50?'Predominio claro':totalClouds<80?'Parcial nublado':'Nublado');
|
||
|
||
// pico vento próximas 24h
|
||
let peakKn=speedKn;
|
||
const tsArr=m.ts||[];const uArr=m['wind_u-surface']||[];const vArr=m['wind_v-surface']||[];
|
||
const cutoff=Date.now()+24*3600*1000;
|
||
for(let i=0;i<tsArr.length&&tsArr[i]<cutoff;i++){
|
||
const s=Math.sqrt(uArr[i]*uArr[i]+vArr[i]*vArr[i])*1.94384;
|
||
if(s>peakKn)peakKn=s;
|
||
}
|
||
const peakNote=peakKn>speedKn+3?` · pico ${peakKn.toFixed(0)}kn 24h`:'';
|
||
|
||
// ondas
|
||
let waveText='';
|
||
if(d.waves){
|
||
const wh=d.waves['waves_height-surface']?.[0];
|
||
const wp=d.waves['waves_period-surface']?.[0];
|
||
const wd=d.waves['waves_direction-surface']?.[0];
|
||
const sh=d.waves['swell1_height-surface']?.[0];
|
||
const sp=d.waves['swell1_period-surface']?.[0];
|
||
if(wh!=null)waveText=`<br>🌊 ${wh.toFixed(1)}m${wp?` · ${wp.toFixed(0)}s`:''}${wd!=null?` · ${windCardinal(wd)}`:''}${sh!=null&&sp?` · swell ${sh.toFixed(1)}m/${sp.toFixed(0)}s`:''}`;
|
||
}
|
||
|
||
el.innerHTML=`<div style="background:var(--bg-paper);border:1px solid var(--rule);padding:10px 14px;margin-bottom:14px;display:grid;grid-template-columns:1fr auto auto;gap:14px;align-items:center;position:relative">
|
||
<div>
|
||
<div class="label-mono" style="margin-bottom:2px">${escapeHtml(desc)} ${cloudIcon}${precipIcon?' '+precipIcon:''} <span style="opacity:.6">· Windy ${escapeHtml(d.model)}</span></div>
|
||
<div style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);font-variant-numeric:tabular-nums">
|
||
💨 ${speedKn.toFixed(0)} kn ${dirCard}${gustKn>speedKn+1?` · rajada ${gustKn.toFixed(0)}`:''}${peakNote}${waveText}
|
||
${pressHpa?`<br>📊 ${pressHpa.toFixed(0)} hPa`:''}
|
||
</div>
|
||
</div>
|
||
<div style="font-family:var(--f-display);font-style:italic;font-size:24px;color:var(--ink-deep);font-variant-numeric:tabular-nums">${tempC!=null?tempC.toFixed(0)+'°':''}</div>
|
||
<button class="icon-btn" onclick="refreshWeather()" title="Atualizar">↻</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderOpenMeteoWeather(el,d){
|
||
const cur=d.forecast?.current;
|
||
if(!cur){el.innerHTML='';return}
|
||
const wave=d.marine?.current;
|
||
const code=cur.weather_code;
|
||
const desc=WMO_CODES[code]||'—';
|
||
const icon=WMO_ICONS[code]||'';
|
||
const wDir=windCardinal(cur.wind_direction_10m);
|
||
const hourly=d.forecast?.hourly;
|
||
let peakWind=cur.wind_speed_10m;
|
||
if(hourly&&hourly.wind_speed_10m){
|
||
const next6=hourly.wind_speed_10m.slice(0,6);
|
||
peakWind=Math.max(...next6,cur.wind_speed_10m);
|
||
}
|
||
const peakNote=peakWind>cur.wind_speed_10m+3?` · pico ${peakWind.toFixed(0)}kn 6h`:'';
|
||
el.innerHTML=`<div style="background:var(--bg-paper);border:1px solid var(--rule);padding:10px 14px;margin-bottom:14px;display:grid;grid-template-columns:1fr auto auto;gap:14px;align-items:center">
|
||
<div>
|
||
<div class="label-mono" style="margin-bottom:2px">${escapeHtml(desc)} ${icon} <span style="opacity:.6">· Open-Meteo</span></div>
|
||
<div style="font-family:var(--f-mono);font-size:13px;color:var(--ink-deep);font-variant-numeric:tabular-nums">
|
||
💨 ${cur.wind_speed_10m.toFixed(0)} kn ${wDir}${cur.wind_gusts_10m?` · rajada ${cur.wind_gusts_10m.toFixed(0)}`:''}${peakNote}
|
||
${wave?`<br>🌊 ${wave.wave_height.toFixed(1)}m${wave.wave_period?` · ${wave.wave_period.toFixed(0)}s`:''}`:''}
|
||
</div>
|
||
</div>
|
||
<div style="font-family:var(--f-display);font-style:italic;font-size:24px;color:var(--ink-deep);font-variant-numeric:tabular-nums">${cur.temperature_2m.toFixed(0)}°</div>
|
||
<button class="icon-btn" onclick="refreshWeather()" title="Atualizar">↻</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function refreshWeather(){
|
||
// tenta usar última posição GPS conhecida
|
||
let pos=null;
|
||
if(tracking.points.length){const p=tracking.points[tracking.points.length-1];pos={lat:p.lat,lng:p.lng}}
|
||
else if(anchorWatch.lastPos)pos=anchorWatch.lastPos;
|
||
else if(anchorWatch.anchorPos)pos=anchorWatch.anchorPos;
|
||
else if(state.anchorHistory.length)pos=state.anchorHistory[0].anchorPos;
|
||
if(!pos&&navigator.geolocation){
|
||
try{
|
||
const p=await new Promise((r,j)=>navigator.geolocation.getCurrentPosition(r,j,{enableHighAccuracy:false,timeout:10000,maximumAge:600000}));
|
||
pos={lat:p.coords.latitude,lng:p.coords.longitude};
|
||
}catch(e){toast('Permita o GPS para obter previsão');return}
|
||
}
|
||
if(!pos){toast('Sem posição para consultar');return}
|
||
toast('Consultando previsão...');
|
||
await fetchWeather(pos.lat,pos.lng);
|
||
toast('Previsão atualizada');
|
||
}
|
||
|
||
function maybeAutoFetchWeather(){
|
||
// auto-fetch se nunca buscou OU a posição mudou >10km OU faz mais de 1h
|
||
const now=Date.now();
|
||
let pos=null;
|
||
if(anchorWatch.lastPos)pos=anchorWatch.lastPos;
|
||
else if(tracking.points.length){const p=tracking.points[tracking.points.length-1];pos={lat:p.lat,lng:p.lng}}
|
||
if(!pos)return;
|
||
if(weather.lastFetch&&now-weather.lastFetch<3600000){
|
||
if(weather.lastPos){
|
||
const moved=haversine(weather.lastPos,pos);
|
||
if(moved<10000)return;
|
||
}else return;
|
||
}
|
||
fetchWeather(pos.lat,pos.lng);
|
||
}
|
||
|
||
function bindWeatherInputs(){
|
||
const k=document.getElementById('windy-key');
|
||
const m=document.getElementById('windy-model');
|
||
if(!k||!m)return;
|
||
k.value=state.weatherCfg?.windyKey||'';
|
||
m.value=state.weatherCfg?.model||'gfs';
|
||
// status
|
||
const st=document.getElementById('windy-status');
|
||
if(state.weatherCfg?.windyKey)st.innerHTML=`<span style="color:var(--algae)">Chave configurada · usando Windy ${state.weatherCfg.model}</span>`;
|
||
else st.innerHTML=`<span style="color:var(--sepia)">Sem chave · usando Open-Meteo (grátis)</span>`;
|
||
}
|
||
|
||
function saveWeatherKey(){
|
||
state.weatherCfg={windyKey:document.getElementById('windy-key').value.trim(),model:document.getElementById('windy-model').value};
|
||
saveState();
|
||
bindWeatherInputs();
|
||
// limpa cache para forçar próxima busca usar novo provider
|
||
weather.lastFetch=0;
|
||
toast('Salvo · forçando nova busca');
|
||
refreshWeather();
|
||
}
|
||
|
||
async function testWindyKey(){
|
||
const key=document.getElementById('windy-key').value.trim();
|
||
const model=document.getElementById('windy-model').value;
|
||
if(!key){document.getElementById('windy-status').innerHTML='<span style="color:var(--storm)">Cole a chave primeiro</span>';return}
|
||
document.getElementById('windy-status').innerHTML='Testando...';
|
||
try{
|
||
// tenta uma posição genérica (Rio de Janeiro)
|
||
const r=await fetch('https://api.windy.com/api/point-forecast/v2',{
|
||
method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({lat:-23,lon:-43,model,parameters:['wind','temp'],levels:['surface'],key})
|
||
});
|
||
if(!r.ok){
|
||
const txt=await r.text();
|
||
document.getElementById('windy-status').innerHTML=`<span style="color:var(--storm)">Erro ${r.status}: ${escapeHtml(txt.slice(0,140))}</span>`;
|
||
return;
|
||
}
|
||
const j=await r.json();
|
||
const pts=j.ts?j.ts.length:0;
|
||
document.getElementById('windy-status').innerHTML=`<span style="color:var(--algae)">✓ OK · ${pts} pontos do modelo ${model} retornados</span>`;
|
||
toast('Chave válida');
|
||
}catch(e){
|
||
document.getElementById('windy-status').innerHTML=`<span style="color:var(--storm)">Falhou: ${escapeHtml(e.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
const battery={level:1,charging:true,saverMode:'auto',forced:'',handler:null};
|
||
|
||
async function initBattery(){
|
||
if(!navigator.getBattery)return;
|
||
try{
|
||
battery.handler=await navigator.getBattery();
|
||
const sync=()=>{battery.level=battery.handler.level;battery.charging=battery.handler.charging;updateBatteryUI()};
|
||
sync();
|
||
battery.handler.addEventListener('levelchange',sync);
|
||
battery.handler.addEventListener('chargingchange',sync);
|
||
}catch(e){}
|
||
}
|
||
|
||
function effectiveBatteryMode(){
|
||
// forced > auto baseado no nível
|
||
if(battery.forced)return battery.forced;
|
||
if(battery.charging)return 'normal';
|
||
if(battery.level>0.5)return 'normal';
|
||
if(battery.level>0.2)return 'eco';
|
||
if(battery.level>0.1)return 'low';
|
||
return 'critical';
|
||
}
|
||
|
||
function batteryGPSOptions(){
|
||
const m=effectiveBatteryMode();
|
||
if(m==='normal')return{enableHighAccuracy:true,maximumAge:0,timeout:15000};
|
||
if(m==='eco')return{enableHighAccuracy:true,maximumAge:5000,timeout:20000};
|
||
if(m==='low')return{enableHighAccuracy:false,maximumAge:30000,timeout:25000};
|
||
return{enableHighAccuracy:false,maximumAge:60000,timeout:30000};
|
||
}
|
||
|
||
function batteryDistanceFilter(){
|
||
const m=effectiveBatteryMode();
|
||
return m==='normal'?5:m==='eco'?8:m==='low'?15:25;
|
||
}
|
||
|
||
function batteryTimeFilter(){
|
||
const m=effectiveBatteryMode();
|
||
return m==='normal'?2000:m==='eco'?5000:m==='low'?15000:30000;
|
||
}
|
||
|
||
function setBatteryMode(mode){
|
||
battery.forced=mode==='auto'?'':mode;
|
||
updateBatteryUI();
|
||
// se rastreio ativo, reiniciar GPS com novas opções
|
||
if(tracking.active&&tracking.watchId!=null){
|
||
navigator.geolocation.clearWatch(tracking.watchId);
|
||
startGPS();
|
||
}
|
||
if(anchorWatch.active&&anchorWatch.watchId!=null){
|
||
navigator.geolocation.clearWatch(anchorWatch.watchId);
|
||
startAnchorGPS();
|
||
}
|
||
toast('Modo: '+(mode==='auto'?'automático':mode));
|
||
}
|
||
|
||
function updateBatteryUI(){
|
||
const el=document.getElementById('battery-indicator');
|
||
if(!el)return;
|
||
if(!battery.handler){el.innerHTML='';return}
|
||
const pct=Math.round(battery.level*100);
|
||
const m=effectiveBatteryMode();
|
||
const modeLbl={normal:'normal',eco:'eco',low:'baixo',critical:'crítico'}[m];
|
||
const color=m==='critical'?'var(--storm)':m==='low'?'var(--sun)':m==='eco'?'var(--brass)':'var(--algae)';
|
||
const forced=battery.forced?` (manual)`:'';
|
||
el.innerHTML=`<span style="color:${color}">⚡ ${pct}%${battery.charging?' ↑':''} · GPS ${modeLbl}${forced}</span>`;
|
||
}
|
||
|
||
function cycleBatteryMode(){
|
||
const modes=['auto','normal','eco','low','critical'];
|
||
const cur=battery.forced||'auto';
|
||
const next=modes[(modes.indexOf(cur)+1)%modes.length];
|
||
setBatteryMode(next);
|
||
}
|
||
|
||
// ============ CHECKLISTS ============
|
||
let activeChecklist=null,checkedItems=new Set();
|
||
|
||
function openChecklistsModal(){
|
||
const body=document.getElementById('checklists-body');
|
||
body.innerHTML=state.checklists.map(c=>`
|
||
<div class="contact-card" style="cursor:pointer" onclick="runChecklist('${c.id}')">
|
||
<div class="contact-info">
|
||
<div class="contact-name">${escapeHtml(c.name)}</div>
|
||
<div class="contact-meta">${c.items.length} ${c.items.length===1?'item':'itens'}</div>
|
||
</div>
|
||
<div class="entry-actions">
|
||
<button class="icon-btn" onclick="event.stopPropagation();editChecklist('${c.id}')" title="Editar">✎</button>
|
||
<button class="icon-btn del" onclick="event.stopPropagation();deleteChecklist('${c.id}')" title="Apagar">×</button>
|
||
</div>
|
||
</div>
|
||
`).join('')+`
|
||
<div style="margin-top:14px;padding-top:14px;border-top:1px solid var(--rule-soft)">
|
||
<button class="btn btn-block" onclick="newChecklist()">+ Nova checklist</button>
|
||
</div>`;
|
||
openModal('checklists-modal');
|
||
}
|
||
|
||
function runChecklist(id){
|
||
const c=state.checklists.find(x=>x.id===id);
|
||
if(!c)return;
|
||
activeChecklist=c;
|
||
checkedItems.clear();
|
||
closeModal('checklists-modal');
|
||
document.getElementById('run-checklist-title').textContent=c.name;
|
||
renderRunChecklist();
|
||
openModal('run-checklist-modal');
|
||
}
|
||
|
||
function renderRunChecklist(){
|
||
if(!activeChecklist)return;
|
||
const body=document.getElementById('run-checklist-body');
|
||
body.innerHTML=activeChecklist.items.map(it=>{
|
||
const checked=checkedItems.has(it.id);
|
||
return `<label style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;margin-bottom:6px;background:${checked?'var(--algae-soft)':'var(--bg-canvas)'};border:1px solid ${checked?'var(--algae)':'var(--rule)'};cursor:pointer;transition:all .15s">
|
||
<input type="checkbox" ${checked?'checked':''} onchange="toggleChecklistItem('${it.id}')" style="margin-top:3px;flex-shrink:0;width:20px;height:20px;accent-color:var(--algae)">
|
||
<span style="font-family:var(--f-display);font-style:italic;font-size:15px;color:var(--ink-deep);${checked?'text-decoration:line-through;opacity:.7':''}">${escapeHtml(it.text)}</span>
|
||
</label>`;
|
||
}).join('');
|
||
const total=activeChecklist.items.length;
|
||
const done=checkedItems.size;
|
||
const prog=document.getElementById('run-checklist-progress');
|
||
if(done===total&&total>0)prog.innerHTML=`<span style="color:var(--algae)">✓ COMPLETO ${done}/${total}</span>`;
|
||
else if(done>0)prog.innerHTML=`<span style="color:var(--brass)">${done}/${total}</span>`;
|
||
else prog.textContent=`${done}/${total}`;
|
||
}
|
||
|
||
function toggleChecklistItem(id){
|
||
if(checkedItems.has(id))checkedItems.delete(id);
|
||
else checkedItems.add(id);
|
||
renderRunChecklist();
|
||
}
|
||
|
||
function resetCurrentChecklist(){checkedItems.clear();renderRunChecklist();toast('Limpo')}
|
||
|
||
function newChecklist(){
|
||
const name=prompt('Nome da checklist:');
|
||
if(!name||!name.trim())return;
|
||
const c={id:uid(),name:name.trim(),items:[]};
|
||
state.checklists.push(c);
|
||
saveState();
|
||
editChecklist(c.id);
|
||
}
|
||
|
||
function editChecklist(id){
|
||
const c=state.checklists.find(x=>x.id===id);
|
||
if(!c)return;
|
||
closeModal('checklists-modal');
|
||
document.getElementById('run-checklist-title').textContent='Editar: '+c.name;
|
||
const body=document.getElementById('run-checklist-body');
|
||
body.innerHTML=`
|
||
<div class="field"><label class="field-label">Nome</label><input id="cl-edit-name" value="${escapeHtml(c.name)}" oninput="state.checklists.find(x=>x.id==='${c.id}').name=this.value;saveState()"></div>
|
||
<div class="field"><label class="field-label">Itens</label>
|
||
${c.items.map(it=>`
|
||
<div style="display:flex;gap:6px;margin-bottom:5px">
|
||
<input value="${escapeHtml(it.text)}" oninput="state.checklists.find(x=>x.id==='${c.id}').items.find(i=>i.id==='${it.id}').text=this.value;saveState()" style="flex:1;font-family:var(--f-display);font-style:italic;font-size:14px">
|
||
<button class="btn btn-sm" onclick="(()=>{const cl=state.checklists.find(x=>x.id==='${c.id}');cl.items=cl.items.filter(i=>i.id!=='${it.id}');saveState();editChecklist('${c.id}')})()">×</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
<button class="btn btn-block btn-sm" onclick="(()=>{const cl=state.checklists.find(x=>x.id==='${c.id}');cl.items.push({id:uid(),text:''});saveState();editChecklist('${c.id}')})()">+ Adicionar item</button>
|
||
<button class="btn btn-block" style="margin-top:10px" onclick="closeModal('run-checklist-modal');openChecklistsModal()">Voltar</button>`;
|
||
document.getElementById('run-checklist-progress').innerHTML='';
|
||
openModal('run-checklist-modal');
|
||
}
|
||
|
||
function deleteChecklist(id){
|
||
if(!confirm('Apagar esta checklist?'))return;
|
||
state.checklists=state.checklists.filter(c=>c.id!==id);
|
||
saveState();
|
||
openChecklistsModal();
|
||
}
|
||
|
||
function trackToGPX(trip){
|
||
const t=trip.track;
|
||
if(!t||!t.points.length)return null;
|
||
const name=trip.destination||'Travessia';
|
||
const startTs=t.startedAt||t.points[0].ts||Date.now();
|
||
const xmlEsc=s=>String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||
const ptXml=t.points.map(p=>{
|
||
const time=p.ts?new Date(p.ts).toISOString():new Date(startTs).toISOString();
|
||
return ` <trkpt lat="${p.lat}" lon="${p.lng}"><time>${time}</time>${p.spd?`<speed>${p.spd}</speed>`:''}</trkpt>`;
|
||
}).join('\n');
|
||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||
<gpx version="1.1" creator="Shivao Diário de Bordo" xmlns="http://www.topografix.com/GPX/1/1">
|
||
<metadata>
|
||
<name>${xmlEsc(name)}</name>
|
||
<time>${new Date(startTs).toISOString()}</time>
|
||
</metadata>
|
||
<trk>
|
||
<name>${xmlEsc(name)}</name>
|
||
<trkseg>
|
||
${ptXml}
|
||
</trkseg>
|
||
</trk>
|
||
</gpx>`;
|
||
}
|
||
|
||
function exportTripGPX(tripId){
|
||
const t=state.trips.find(x=>x.id===tripId);
|
||
if(!t||!t.track){toast('Sem rastreio para exportar');return}
|
||
const xml=trackToGPX(t);
|
||
const blob=new Blob([xml],{type:'application/gpx+xml'});
|
||
triggerDownload(blob,`${slug(t.destination||'travessia')}-${(t.dateStart||todayISO()).replace(/-/g,'')}.gpx`);
|
||
toast('GPX baixado');
|
||
}
|
||
|
||
// ============ GEOFENCING / ZONES ============
|
||
let zoneMap=null,zoneMarker=null,zoneCircle=null,editingZone=null,pendingZoneCenter=null;
|
||
const zoneEnterState=new Map(); // zoneId → boolean (currently inside?)
|
||
|
||
function renderZones(){
|
||
const list=document.getElementById('zones-list');
|
||
if(!state.zones.length){
|
||
list.innerHTML=`<div class="empty">${COMPASS_ROSE_SVG}<div class="empty-title">Nenhuma zona marcada</div><div class="empty-text">Crie zonas para receber alertas ao se aproximar (pedras, áreas restritas, marina, ondas).</div></div>`;
|
||
return;
|
||
}
|
||
list.innerHTML=state.zones.map(z=>{
|
||
const typeLabel=z.type==='forbidden'?'⛔ Proibida':'⚠️ Atenção';
|
||
return `<div class="zone-entry ${z.type}" onclick="openZoneEditor('${z.id}')">
|
||
<div class="zone-entry-name">${escapeHtml(z.name||'sem nome')}</div>
|
||
<div class="zone-entry-meta">${typeLabel} · raio ${z.radius}m · ${z.center.lat.toFixed(5)}, ${z.center.lng.toFixed(5)}${z.notes?'<br>'+escapeHtml(z.notes):''}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function openZoneEditor(id){
|
||
editingZone=id?state.zones.find(z=>z.id===id):null;
|
||
pendingZoneCenter=editingZone?{...editingZone.center}:null;
|
||
document.getElementById('zone-edit-title').textContent=editingZone?'Editar Zona':'Nova Zona';
|
||
document.getElementById('zone-name').value=editingZone?.name||'';
|
||
document.getElementById('zone-type').value=editingZone?.type||'forbidden';
|
||
const r=editingZone?.radius||100;
|
||
document.getElementById('zone-radius').value=r;
|
||
document.getElementById('zone-radius-val').textContent=r+' m';
|
||
document.getElementById('zone-delete-btn').style.display=editingZone?'inline-flex':'none';
|
||
document.getElementById('zone-edit-help').textContent=editingZone?'Toque no mapa para mover o centro':'Toque no mapa para marcar o centro da zona';
|
||
openModal('zone-edit-modal');
|
||
setTimeout(()=>{
|
||
if(zoneMap){zoneMap.remove();zoneMap=null;zoneMarker=null;zoneCircle=null}
|
||
let initLat=-23,initLng=-43,initZoom=12;
|
||
if(editingZone){initLat=editingZone.center.lat;initLng=editingZone.center.lng;initZoom=15}
|
||
else if(anchorWatch.lastPos){initLat=anchorWatch.lastPos.lat;initLng=anchorWatch.lastPos.lng;initZoom=15}
|
||
else if(tracking.points.length){const last=tracking.points[tracking.points.length-1];initLat=last.lat;initLng=last.lng;initZoom=15}
|
||
zoneMap=L.map('zone-map').setView([initLat,initLng],initZoom);
|
||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(zoneMap);
|
||
// mostrar zonas existentes (exceto a editada)
|
||
state.zones.forEach(z=>{
|
||
if(editingZone&&z.id===editingZone.id)return;
|
||
L.circle([z.center.lat,z.center.lng],{radius:z.radius,color:z.type==='forbidden'?'#8c3434':'#b67025',fillColor:z.type==='forbidden'?'#8c3434':'#b67025',fillOpacity:.15,weight:1.5,opacity:.6}).addTo(zoneMap);
|
||
});
|
||
if(pendingZoneCenter)drawZonePreview();
|
||
zoneMap.on('click',e=>{
|
||
pendingZoneCenter={lat:e.latlng.lat,lng:e.latlng.lng};
|
||
drawZonePreview();
|
||
document.getElementById('zone-edit-help').textContent='Ajuste o raio e o nome';
|
||
});
|
||
setTimeout(()=>zoneMap.invalidateSize(),100);
|
||
},50);
|
||
}
|
||
|
||
function drawZonePreview(){
|
||
if(!zoneMap||!pendingZoneCenter)return;
|
||
const ll=[pendingZoneCenter.lat,pendingZoneCenter.lng];
|
||
const type=document.getElementById('zone-type').value;
|
||
const color=type==='forbidden'?'#8c3434':'#b67025';
|
||
if(!zoneMarker)zoneMarker=L.marker(ll,{draggable:true}).addTo(zoneMap);
|
||
else zoneMarker.setLatLng(ll);
|
||
zoneMarker.off('dragend');
|
||
zoneMarker.on('dragend',e=>{const p=e.target.getLatLng();pendingZoneCenter={lat:p.lat,lng:p.lng};drawZonePreview()});
|
||
if(zoneCircle)zoneCircle.remove();
|
||
const r=parseInt(document.getElementById('zone-radius').value);
|
||
zoneCircle=L.circle(ll,{radius:r,color,fillColor:color,fillOpacity:.2,weight:2}).addTo(zoneMap);
|
||
}
|
||
|
||
function updateZoneRadius(v){
|
||
document.getElementById('zone-radius-val').textContent=v+' m';
|
||
if(zoneCircle&&pendingZoneCenter){
|
||
zoneCircle.setRadius(parseInt(v));
|
||
}
|
||
}
|
||
|
||
document.getElementById('zone-type').addEventListener('change',()=>{if(pendingZoneCenter)drawZonePreview()});
|
||
|
||
function saveZone(){
|
||
if(!pendingZoneCenter){toast('Toque no mapa para marcar o centro');return}
|
||
const name=document.getElementById('zone-name').value.trim()||'Zona';
|
||
const type=document.getElementById('zone-type').value;
|
||
const radius=parseInt(document.getElementById('zone-radius').value);
|
||
const data={id:editingZone?.id||uid(),name,type,center:pendingZoneCenter,radius,createdAt:editingZone?.createdAt||Date.now()};
|
||
if(editingZone){const idx=state.zones.findIndex(z=>z.id===editingZone.id);state.zones[idx]=data}
|
||
else state.zones.unshift(data);
|
||
saveState();
|
||
closeZoneEditor();
|
||
renderZones();
|
||
toast('Zona salva');
|
||
}
|
||
|
||
function deleteZone(){
|
||
if(!editingZone)return;
|
||
if(!confirm('Apagar esta zona?'))return;
|
||
state.zones=state.zones.filter(z=>z.id!==editingZone.id);
|
||
zoneEnterState.delete(editingZone.id);
|
||
saveState();
|
||
closeZoneEditor();
|
||
renderZones();
|
||
toast('Zona apagada');
|
||
}
|
||
|
||
function closeZoneEditor(){
|
||
closeModal('zone-edit-modal');
|
||
if(zoneMap){setTimeout(()=>{zoneMap.remove();zoneMap=null;zoneMarker=null;zoneCircle=null;pendingZoneCenter=null;editingZone=null},300)}
|
||
}
|
||
|
||
// Detection — chamada de onGPSUpdate e onAnchorGPSUpdate
|
||
function checkZones(curPos){
|
||
if(!state.zones||!state.zones.length)return;
|
||
for(const z of state.zones){
|
||
const d=haversine(z.center,curPos);
|
||
const inside=d<=z.radius;
|
||
const wasInside=zoneEnterState.get(z.id)||false;
|
||
if(inside&&!wasInside){
|
||
zoneEnterState.set(z.id,true);
|
||
onZoneEnter(z);
|
||
}else if(!inside&&wasInside){
|
||
zoneEnterState.set(z.id,false);
|
||
onZoneExit(z);
|
||
}
|
||
}
|
||
}
|
||
|
||
function onZoneEnter(z){
|
||
console.log('[zone] entered',z.name,z.type);
|
||
showZoneToast(`Entrou em ${z.name}`,z.type);
|
||
if(z.type==='forbidden'){
|
||
// alarme curto + vibração forte
|
||
playAlarmSound();
|
||
if('vibrate' in navigator)navigator.vibrate([300,100,300,100,300]);
|
||
setTimeout(stopAlarmSound,3500);
|
||
// dispatch webhooks com mensagem de zona
|
||
const text=`⛔ ${state.boat.name||'Veleiro'} entrou em zona PROIBIDA: ${z.name}`;
|
||
dispatchWebhooks(text).catch(e=>{console.error('webhook zone alarm failed:',e);toast('Falha ao enviar alarme remoto')});
|
||
}else{
|
||
if('vibrate' in navigator)navigator.vibrate([200,80,200]);
|
||
}
|
||
}
|
||
|
||
function onZoneExit(z){
|
||
console.log('[zone] exited',z.name);
|
||
if(z.type==='forbidden')showZoneToast(`Saiu de ${z.name}`,'attention');
|
||
}
|
||
|
||
function showZoneToast(msg,kind){
|
||
const t=document.getElementById('zone-toast');
|
||
t.textContent=msg;
|
||
t.className='zone-toast show'+(kind==='attention'?' warn':'');
|
||
clearTimeout(showZoneToast._tm);
|
||
showZoneToast._tm=setTimeout(()=>t.className='zone-toast',5000);
|
||
}
|
||
|
||
// Hook into existing GPS handlers
|
||
const _origOnGPSUpdate=onGPSUpdate;
|
||
onGPSUpdate=function(pos){_origOnGPSUpdate(pos);if(pos&&pos.coords)checkZones({lat:pos.coords.latitude,lng:pos.coords.longitude})};
|
||
const _origOnAnchorGPSUpdate=onAnchorGPSUpdate;
|
||
onAnchorGPSUpdate=function(pos){_origOnAnchorGPSUpdate(pos);if(pos&&pos.coords&&pos.coords.accuracy<=50)checkZones({lat:pos.coords.latitude,lng:pos.coords.longitude})};
|
||
|
||
// Render zones on tracking + anchor maps
|
||
const _origUpdateTrackingMap=updateTrackingMap;
|
||
updateTrackingMap=function(p){_origUpdateTrackingMap(p);drawZonesOnMap(trackingMap)};
|
||
const _origDrawAnchorOnMap=drawAnchorOnMap;
|
||
drawAnchorOnMap=function(){_origDrawAnchorOnMap();drawZonesOnMap(anchorMap)};
|
||
|
||
const drawnZonesByMap=new WeakMap();
|
||
function drawZonesOnMap(map){
|
||
if(!map||!state.zones||!state.zones.length)return;
|
||
let layers=drawnZonesByMap.get(map);
|
||
if(layers)layers.forEach(l=>l.remove());
|
||
layers=[];
|
||
state.zones.forEach(z=>{
|
||
const color=z.type==='forbidden'?'#8c3434':'#b67025';
|
||
const c=L.circle([z.center.lat,z.center.lng],{radius:z.radius,color,fillColor:color,fillOpacity:.15,weight:1.5,opacity:.6}).addTo(map);
|
||
layers.push(c);
|
||
});
|
||
drawnZonesByMap.set(map,layers);
|
||
}
|
||
|
||
// ============ GPX IMPORT ============
|
||
function parseGPX(text){
|
||
const parser=new DOMParser();
|
||
const doc=parser.parseFromString(text,'application/xml');
|
||
if(doc.querySelector('parsererror'))throw new Error('XML inválido');
|
||
const points=[];
|
||
doc.querySelectorAll('trkpt').forEach(pt=>{
|
||
const lat=parseFloat(pt.getAttribute('lat'));
|
||
const lng=parseFloat(pt.getAttribute('lon'));
|
||
if(isNaN(lat)||isNaN(lng))return;
|
||
const timeEl=pt.querySelector('time');
|
||
const ts=timeEl?new Date(timeEl.textContent).getTime():0;
|
||
const speedEl=pt.querySelector('speed,extensions speed');
|
||
const spd=speedEl?parseFloat(speedEl.textContent):0;
|
||
points.push({lat,lng,ts,spd:isNaN(spd)?0:spd});
|
||
});
|
||
// se não tiver trkpt, tenta rtept (route points)
|
||
if(!points.length){
|
||
doc.querySelectorAll('rtept').forEach(pt=>{
|
||
const lat=parseFloat(pt.getAttribute('lat'));
|
||
const lng=parseFloat(pt.getAttribute('lon'));
|
||
if(!isNaN(lat)&&!isNaN(lng))points.push({lat,lng,ts:0,spd:0});
|
||
});
|
||
}
|
||
if(!points.length)throw new Error('Nenhum ponto encontrado no GPX');
|
||
// Nome da trilha
|
||
const nameEl=doc.querySelector('trk > name, rte > name, metadata > name');
|
||
const name=nameEl?nameEl.textContent.trim():'';
|
||
// Calcular estatísticas
|
||
let dist=0,maxSpd=0;
|
||
for(let i=1;i<points.length;i++){
|
||
dist+=haversine(points[i-1],points[i]);
|
||
if(points[i].spd>maxSpd)maxSpd=points[i].spd;
|
||
}
|
||
const haveTime=points[0].ts>0&&points[points.length-1].ts>0;
|
||
const startedAt=haveTime?points[0].ts:Date.now();
|
||
const endedAt=haveTime?points[points.length-1].ts:Date.now();
|
||
const dur=(endedAt-startedAt)/1000;
|
||
// se não tiver speeds individuais, calcular máx pela velocidade ponto-a-ponto
|
||
if(maxSpd===0&&haveTime){
|
||
for(let i=1;i<points.length;i++){
|
||
const dt=(points[i].ts-points[i-1].ts)/1000;
|
||
if(dt>0){const sp=haversine(points[i-1],points[i])/dt;if(sp>maxSpd)maxSpd=sp}
|
||
}
|
||
}
|
||
const avgSpd=dur>0?dist/dur:0;
|
||
return {
|
||
name,
|
||
track:{startedAt,endedAt,points,distance:dist,maxSpeed:maxSpd,avgSpeed:avgSpd}
|
||
};
|
||
}
|
||
|
||
async function importGPX(event){
|
||
const file=event.target.files[0];if(!file)return;
|
||
try{
|
||
const text=await file.text();
|
||
const{name,track}=parseGPX(text);
|
||
event.target.value='';
|
||
toast(`GPX: ${track.points.length} pts · ${metersToNM(track.distance).toFixed(2)}mn`);
|
||
// abrir modal de viagem com track preenchido
|
||
await openTripModal(null,track);
|
||
// pré-preencher destino com nome do GPX se disponível
|
||
if(name)document.getElementById('trip-destination').value=name;
|
||
}catch(e){
|
||
alert('Falha ao importar GPX: '+e.message);
|
||
event.target.value='';
|
||
}
|
||
}
|
||
|
||
// ============ LIVE SHARE ============
|
||
let sharePositionInterval=null;
|
||
|
||
async function createShare(){
|
||
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
|
||
const dur=parseInt(document.getElementById('share-duration').value);
|
||
try{
|
||
const r=await cloudFetch('/api/share/create',{method:'POST',body:JSON.stringify({durationMinutes:dur,boatName:state.boat.name,zones:state.zones||[]})});
|
||
const{token,url,expiresAt}=await r.json();
|
||
const shareUrl=url||(state.cloud.url.replace(/\/$/,'')+'/share/'+token);
|
||
const expDate=new Date(expiresAt).toLocaleString('pt-BR');
|
||
document.getElementById('share-result').innerHTML=`
|
||
<strong style="color:var(--algae)">Link criado · expira ${expDate}</strong><br>
|
||
<input value="${shareUrl}" readonly onclick="this.select()" style="width:100%;padding:6px 8px;font-family:var(--f-mono);font-size:11px;background:var(--bg-canvas);border:1px solid var(--rule);margin-top:6px">
|
||
<div style="display:flex;gap:6px;margin-top:6px"><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${shareUrl}').then(()=>toast('Copiado'))">Copiar</button><button class="btn btn-sm" onclick="shareLink('${shareUrl.replace(/'/g,'')}')">Compartilhar</button></div>`;
|
||
startSharePosting();
|
||
renderShareList();
|
||
}catch(e){toast('Erro: '+e.message)}
|
||
}
|
||
|
||
function shareLink(url){
|
||
if(navigator.share)navigator.share({title:`${state.boat.name||'Shivao'} ao vivo`,text:'Acompanhe a posição do barco em tempo real',url}).catch(()=>{});
|
||
else{navigator.clipboard.writeText(url);toast('Link copiado')}
|
||
}
|
||
|
||
async function renderShareList(){
|
||
const el=document.getElementById('share-active-list');
|
||
if(!el||!cloudConfigured()){if(el)el.innerHTML='';return}
|
||
try{
|
||
const r=await cloudFetch('/api/share/list');
|
||
const list=await r.json();
|
||
if(!list.length){el.innerHTML='';stopSharePosting();return}
|
||
el.innerHTML=`<div class="label-mono" style="margin-bottom:6px">Links ativos</div>`+list.map(s=>{
|
||
const exp=new Date(s.expiresAt).toLocaleString('pt-BR');
|
||
const url=state.cloud.url.replace(/\/$/,'')+'/share/'+s.token;
|
||
return `<div style="background:var(--bg-canvas);border:1px solid var(--rule);padding:8px 10px;margin-bottom:5px;font-family:var(--f-mono);font-size:11px"><div style="display:flex;justify-content:space-between;align-items:center;gap:6px"><div><strong>${escapeHtml(s.boatName||'')}</strong> · expira ${exp}</div><button class="btn btn-sm" onclick="revokeShare('${s.token}')">Revogar</button></div><div style="font-size:10px;color:var(--sepia);margin-top:3px;word-break:break-all">${url}</div></div>`;
|
||
}).join('');
|
||
if(list.length)startSharePosting();
|
||
}catch(e){console.warn('share list',e.message)}
|
||
}
|
||
|
||
async function revokeShare(token){
|
||
if(!confirm('Revogar este link? Quem está vendo perderá o acesso.'))return;
|
||
try{await cloudFetch('/api/share/'+token,{method:'DELETE'});toast('Revogado');renderShareList();}catch(e){toast('Erro: '+e.message)}
|
||
}
|
||
|
||
function startSharePosting(){
|
||
if(sharePositionInterval)return;
|
||
postSharePosition();
|
||
sharePositionInterval=setInterval(postSharePosition,30000);
|
||
}
|
||
function stopSharePosting(){if(sharePositionInterval){clearInterval(sharePositionInterval);sharePositionInterval=null}}
|
||
|
||
async function postSharePosition(){
|
||
if(!cloudConfigured())return;
|
||
// só posta se tiver posição recente (rastreio ou fundeio ativos)
|
||
let pos=null,spd=0;
|
||
if(tracking.active&&tracking.points.length){const p=tracking.points[tracking.points.length-1];pos={lat:p.lat,lng:p.lng};spd=p.spd||0}
|
||
else if(anchorWatch.active&&anchorWatch.lastPos){pos=anchorWatch.lastPos}
|
||
if(!pos){
|
||
// tenta pegar uma posição rápida
|
||
if(!navigator.geolocation)return;
|
||
try{
|
||
const p=await new Promise((r,j)=>navigator.geolocation.getCurrentPosition(r,j,{enableHighAccuracy:false,timeout:8000,maximumAge:60000}));
|
||
pos={lat:p.coords.latitude,lng:p.coords.longitude};
|
||
spd=p.coords.speed||0;
|
||
}catch(e){return}
|
||
}
|
||
try{
|
||
await cloudFetch('/api/share/position',{method:'POST',body:JSON.stringify({lat:pos.lat,lng:pos.lng,speed:spd,boatName:state.boat.name})});
|
||
}catch(e){console.warn('share post',e.message)}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|