feat(charts): cartas náuticas OpenSeaMap (grátis) + slot Navionics + export OpenCPN v1.8.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Cartas náuticas nos mapas (rastreio, fundeio, zonas, viagens):
- OpenSeaMap como overlay padrão grátis (sondas, faróis, bóias, marcas)
- Slot Navionics ativável via chave (após aprovação Garmin)
  - Dynamic load do JNC.Leaflet.NavionicsOverlay quando chave preenchida
- Layer switcher no canto direito do mapa: OSM Padrão / Satélite Esri,
  overlay OpenSeaMap / Navionics
- Helper addMapLayers() centraliza configuração — substituiu 5 usages
  manuais de L.tileLayer espalhados (tracking, trip view, anchor,
  anchor history, zone editor)

Settings (Mais → Cartas Náuticas):
- Dropdown provedor: OpenSeaMap/Navionics/só OSM
- Campo chave Navionics (password) com link pro form Garmin
- Status visual do provedor ativo

Integração OpenCPN (Mais → Exportar para OpenCPN):
- Botão gera GPX consolidado de todo o diário:
  - Tracks: cada viagem com pontos GPS sequenciais
  - Waypoints: cada fundeio histórico com símbolo Anchor
  - Routes: cada zona (forbidden/attention) como polígono fechado
  - Aproximação círculo→polígono 16 pontos pra zonas circulares
- Compatível com OpenCPN, Garmin, Raymarine, B&G, qualquer plotter
  GPX-compliant
- Download direto via Blob URL, sem servidor

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 14:34:24 -03:00
parent f8e92f3c58
commit 0921d98ef3
5 changed files with 456 additions and 14 deletions

View file

@ -1951,6 +1951,33 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
</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>
<!-- Cartas Náuticas -->
<div class="export-card" id="charts-card">
<div class="export-card-title">⚓ Cartas Náuticas</div>
<div class="export-card-text" style="margin-bottom:10px">Provedor de cartas usado nos mapas (rastreio, fundeio, zonas). OpenSeaMap é grátis e cobre o essencial (sondas, faróis, bóias). Navionics requer chave aprovada pela Garmin.</div>
<div class="field"><label class="field-label">Provedor</label>
<select id="chart-provider" onchange="saveChartCfg()">
<option value="opensea">OpenSeaMap (grátis · padrão)</option>
<option value="navionics">Navionics (requer chave)</option>
<option value="osm">Apenas OSM (sem cartas náuticas)</option>
</select>
</div>
<div class="field"><label class="field-label">Chave Navionics (navKey)</label>
<input type="password" id="chart-nav-key" placeholder="cole a chave após aprovação Garmin" style="font-family:var(--f-mono);font-size:11px" onchange="saveChartCfg()">
<div class="field-hint" style="margin-top:6px">Solicite em <a href="https://www.garmin.com/en-US/forms/navionics-web-api/" target="_blank" rel="noopener" style="color:var(--m-accent,#06b6d4);text-decoration:underline">garmin.com/forms/navionics-web-api</a>. Após aprovação, seu domínio (shivao.pontualtech.work) precisa ser autorizado pela Navionics.</div>
</div>
<button class="btn btn-block" onclick="testChartCfg()">Testar e salvar</button>
<div id="chart-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>
<!-- Export OpenCPN -->
<div class="export-card" id="opencpn-card">
<div class="export-card-title">🗺️ Exportar para OpenCPN</div>
<div class="export-card-text" style="margin-bottom:10px"><a href="https://opencpn.org/" target="_blank" rel="noopener" style="color:var(--m-accent,#06b6d4);text-decoration:underline">OpenCPN</a> é o app de navegação náutica open-source mais usado em PCs/notebooks. Exporte um GPX consolidado com todas suas viagens, fundeios e zonas pra abrir no OpenCPN ou em qualquer plotter compatível.</div>
<button class="btn btn-block btn-primary" onclick="exportOpenCPN()">↓ Baixar GPX completo</button>
<div class="field-hint" style="margin-top:8px">Inclui: tracks de cada travessia · waypoints de fundeios históricos · routes das zonas demarcadas. Compatível também com Garmin, Raymarine, B&G via importação GPX.</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>
@ -2732,6 +2759,87 @@ function anchorAdvice(windKn,boatType){
return `⚓ Calmo · scope mínimo 5:1`;
}
const STORAGE_KEY='diario_bordo_v3';
// ============ NAUTICAL CHART LAYERS (OpenSeaMap + Navionics) ============
// addMapLayers(map): adiciona base OSM + overlay de cartas náuticas configurável
// Chart provider: salvo em state.chartCfg.provider ('osm'|'opensea'|'navionics')
// Navionics requer state.chartCfg.navKey (obtido via formulário Garmin)
function getChartProvider(){
const cfg=state.chartCfg||{};
return cfg.provider||(cfg.navKey?'navionics':'opensea');
}
function addMapLayers(map){
// Base layer: OSM padrão (sempre)
const osm=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
maxZoom:19,attribution:'© OpenStreetMap',className:'map-tiles-osm'
});
// Base layer: Esri Satélite (alternativo)
const sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{
maxZoom:19,attribution:'Tiles © Esri'
});
// Overlay: OpenSeaMap (cartas náuticas grátis — sondas, faróis, bóias)
const seamap=L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',{
maxZoom:18,attribution:'© OpenSeaMap',opacity:.95,className:'map-tiles-seamark'
});
// Adiciona base padrão
osm.addTo(map);
// Aplica camada náutica conforme config
const provider=getChartProvider();
let nauticalLayer=null;
if(provider==='opensea'){
seamap.addTo(map);
nauticalLayer=seamap;
}else if(provider==='navionics'&&state.chartCfg?.navKey){
nauticalLayer=tryAddNavionicsLayer(map);
if(!nauticalLayer){seamap.addTo(map);nauticalLayer=seamap}
}
// Layer switcher (canto superior direito)
const baseLayers={'OSM Padrão':osm,'Satélite':sat};
const overlayLayers={'OpenSeaMap (cartas grátis)':seamap};
if(state.chartCfg?.navKey){
overlayLayers['Navionics ⚓']={addTo:m=>tryAddNavionicsLayer(m),removeFrom:m=>{}};
}
L.control.layers(baseLayers,overlayLayers,{position:'topright',collapsed:true}).addTo(map);
return map;
}
let _navionicsScriptLoaded=null;
function loadNavionicsScript(){
if(_navionicsScriptLoaded)return _navionicsScriptLoaded;
_navionicsScriptLoaded=new Promise((resolve,reject)=>{
const s=document.createElement('script');
s.src='https://webapiv2.navionics.com/dist/webapi/webapi.min.js';
s.async=true;
s.onload=()=>resolve(window.JNC);
s.onerror=()=>reject(new Error('Navionics script failed'));
document.head.appendChild(s);
});
return _navionicsScriptLoaded;
}
function tryAddNavionicsLayer(map){
const key=state.chartCfg?.navKey;
if(!key)return null;
loadNavionicsScript().then(JNC=>{
if(!JNC?.Leaflet?.NavionicsOverlay)return;
try{
const overlay=new JNC.Leaflet.NavionicsOverlay({
navKey:key,
chartType:JNC.NAVIONICS_CHARTS.NAUTICAL,
isTransparent:true,logoPayoff:true,zIndex:5,
});
overlay.addTo(map);
console.log('[navionics] overlay added');
}catch(e){console.warn('[navionics] add failed',e.message)}
}).catch(e=>console.warn('[navionics] load failed',e.message));
return {addTo:()=>{},removeFrom:()=>{}}; // placeholder pra layer control
}
const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3';
let pendingFilter='active';
@ -2836,7 +2944,7 @@ 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)}
if(!trackingMap){trackingMap=L.map('tracking-map').setView([0,0],2);addMapLayers(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();
@ -3516,7 +3624,7 @@ function openMapModal(tid){
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);
addMapLayers(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]})}
@ -3913,7 +4021,7 @@ function updateAnchorUI(){
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)}
if(!anchorMap){anchorMap=L.map('anchor-map').setView([anchorWatch.anchorPos.lat,anchorWatch.anchorPos.lng],17);addMapLayers(anchorMap)}
setTimeout(()=>anchorMap.invalidateSize(),100);
drawAnchorOnMap();
document.getElementById('aw-radius-slider').value=anchorWatch.radius;
@ -5320,7 +5428,7 @@ function openAnchorHistoryMap(id){
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);
addMapLayers(historyMap);
const a=[h.anchorPos.lat,h.anchorPos.lng];
const sp=h.swingPos||h.anchorPos;
const s=[sp.lat,sp.lng];
@ -5575,6 +5683,119 @@ function bindWeatherInputs(){
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>`;
// bind charts cfg também (mesmo fluxo abrir aba)
bindChartInputs();
}
function bindChartInputs(){
const p=document.getElementById('chart-provider');
const k=document.getElementById('chart-nav-key');
const st=document.getElementById('chart-status');
if(!p||!k||!st)return;
p.value=state.chartCfg?.provider||'opensea';
k.value=state.chartCfg?.navKey||'';
if(state.chartCfg?.provider==='navionics'&&state.chartCfg?.navKey){
st.innerHTML='<span style="color:var(--m-ok,#10b981)">Navionics ativo · cartas oficiais</span>';
}else if(state.chartCfg?.provider==='opensea'||!state.chartCfg?.provider){
st.innerHTML='<span style="color:var(--m-text-mid,#b3c5d6)">OpenSeaMap ativo · cartas náuticas grátis</span>';
}else{
st.innerHTML='<span style="color:var(--m-text-soft,#7d97ad)">Apenas OSM · sem overlay náutico</span>';
}
}
function saveChartCfg(){
state.chartCfg={
provider:document.getElementById('chart-provider').value,
navKey:document.getElementById('chart-nav-key').value.trim(),
};
saveState();
bindChartInputs();
}
function testChartCfg(){
saveChartCfg();
toast('Cartas salvas — abra um mapa pra ver');
}
// ============ EXPORT GPX para OpenCPN ============
function gpxEscape(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]))}
function exportOpenCPN(){
const now=new Date().toISOString();
let gpx=`<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Shivao Diario de Bordo" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Shivao — ${gpxEscape(activeBoat()?.name||'Diário de Bordo')}</name>
<desc>Export consolidado de viagens, fundeios e zonas</desc>
<time>${now}</time>
</metadata>
`;
// Waypoints: anchorages históricos
const anchors=state.anchorHistory||[];
for(const a of anchors){
if(!a.anchorPos)continue;
gpx+=` <wpt lat="${a.anchorPos.lat.toFixed(6)}" lon="${a.anchorPos.lng.toFixed(6)}">
<name>⚓ ${gpxEscape(a.label||'Fundeio '+new Date(a.startedAt||Date.now()).toLocaleDateString('pt-BR'))}</name>
<desc>Raio ${a.radius||'?'}m · ${a.duration?Math.round(a.duration/60000)+'min':'desconhecido'}</desc>
<sym>Anchor</sym>
<type>anchorage</type>
</wpt>
`;
}
// Tracks: cada viagem com pontos GPS
const trips=(state.trips||[]).filter(t=>t.track&&t.track.length>0);
for(const t of trips){
gpx+=` <trk>
<name>${gpxEscape(t.destination||'Travessia '+new Date(t.dateStart||Date.now()).toLocaleDateString('pt-BR'))}</name>
<desc>${gpxEscape((t.notes||'').slice(0,200))}</desc>
<trkseg>
`;
for(const pt of t.track){
const lat=pt.lat||pt[0];
const lng=pt.lng||pt[1];
const ts=pt.t||pt.ts;
if(!lat||!lng)continue;
gpx+=` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}">${ts?`<time>${new Date(ts).toISOString()}</time>`:''}</trkpt>
`;
}
gpx+=` </trkseg>
</trk>
`;
}
// Routes: zonas (forbidden/attention) como rotas fechadas
const zones=state.zones||[];
for(const z of zones){
if(!z.center||!z.radius)continue;
// Aproxima círculo como polígono de 16 pontos
const points=[];
for(let i=0;i<=16;i++){
const angle=(i/16)*2*Math.PI;
const dLat=(z.radius/111000)*Math.cos(angle);
const dLng=(z.radius/(111000*Math.cos(z.center.lat*Math.PI/180)))*Math.sin(angle);
points.push([z.center.lat+dLat,z.center.lng+dLng]);
}
gpx+=` <rte>
<name>${z.kind==='forbidden'?'⛔ ':'⚠ '}${gpxEscape(z.name||'Zona')}</name>
<desc>${z.kind} · raio ${Math.round(z.radius)}m</desc>
`;
for(const [lat,lng] of points){
gpx+=` <rtept lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"></rtept>
`;
}
gpx+=` </rte>
`;
}
gpx+=`</gpx>`;
// Download
const blob=new Blob([gpx],{type:'application/gpx+xml'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;
a.download=`shivao-${new Date().toISOString().slice(0,10)}.gpx`;
document.body.appendChild(a);a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
toast(`GPX gerado · ${trips.length} tracks · ${anchors.length} fundeios · ${zones.length} zonas`);
}
function saveWeatherKey(){
@ -6066,7 +6287,7 @@ function openZoneEditor(id){
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);
addMapLayers(zoneMap);
// mostrar zonas existentes (exceto a editada)
state.zones.forEach(z=>{
if(editingZone&&z.id===editingZone.id)return;

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "shivao-mobile",
"version": "1.7.1",
"version": "1.8.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",

View file

@ -1951,6 +1951,33 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
</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>
<!-- Cartas Náuticas -->
<div class="export-card" id="charts-card">
<div class="export-card-title">⚓ Cartas Náuticas</div>
<div class="export-card-text" style="margin-bottom:10px">Provedor de cartas usado nos mapas (rastreio, fundeio, zonas). OpenSeaMap é grátis e cobre o essencial (sondas, faróis, bóias). Navionics requer chave aprovada pela Garmin.</div>
<div class="field"><label class="field-label">Provedor</label>
<select id="chart-provider" onchange="saveChartCfg()">
<option value="opensea">OpenSeaMap (grátis · padrão)</option>
<option value="navionics">Navionics (requer chave)</option>
<option value="osm">Apenas OSM (sem cartas náuticas)</option>
</select>
</div>
<div class="field"><label class="field-label">Chave Navionics (navKey)</label>
<input type="password" id="chart-nav-key" placeholder="cole a chave após aprovação Garmin" style="font-family:var(--f-mono);font-size:11px" onchange="saveChartCfg()">
<div class="field-hint" style="margin-top:6px">Solicite em <a href="https://www.garmin.com/en-US/forms/navionics-web-api/" target="_blank" rel="noopener" style="color:var(--m-accent,#06b6d4);text-decoration:underline">garmin.com/forms/navionics-web-api</a>. Após aprovação, seu domínio (shivao.pontualtech.work) precisa ser autorizado pela Navionics.</div>
</div>
<button class="btn btn-block" onclick="testChartCfg()">Testar e salvar</button>
<div id="chart-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>
<!-- Export OpenCPN -->
<div class="export-card" id="opencpn-card">
<div class="export-card-title">🗺️ Exportar para OpenCPN</div>
<div class="export-card-text" style="margin-bottom:10px"><a href="https://opencpn.org/" target="_blank" rel="noopener" style="color:var(--m-accent,#06b6d4);text-decoration:underline">OpenCPN</a> é o app de navegação náutica open-source mais usado em PCs/notebooks. Exporte um GPX consolidado com todas suas viagens, fundeios e zonas pra abrir no OpenCPN ou em qualquer plotter compatível.</div>
<button class="btn btn-block btn-primary" onclick="exportOpenCPN()">↓ Baixar GPX completo</button>
<div class="field-hint" style="margin-top:8px">Inclui: tracks de cada travessia · waypoints de fundeios históricos · routes das zonas demarcadas. Compatível também com Garmin, Raymarine, B&G via importação GPX.</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>
@ -2732,6 +2759,87 @@ function anchorAdvice(windKn,boatType){
return `⚓ Calmo · scope mínimo 5:1`;
}
const STORAGE_KEY='diario_bordo_v3';
// ============ NAUTICAL CHART LAYERS (OpenSeaMap + Navionics) ============
// addMapLayers(map): adiciona base OSM + overlay de cartas náuticas configurável
// Chart provider: salvo em state.chartCfg.provider ('osm'|'opensea'|'navionics')
// Navionics requer state.chartCfg.navKey (obtido via formulário Garmin)
function getChartProvider(){
const cfg=state.chartCfg||{};
return cfg.provider||(cfg.navKey?'navionics':'opensea');
}
function addMapLayers(map){
// Base layer: OSM padrão (sempre)
const osm=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
maxZoom:19,attribution:'© OpenStreetMap',className:'map-tiles-osm'
});
// Base layer: Esri Satélite (alternativo)
const sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{
maxZoom:19,attribution:'Tiles © Esri'
});
// Overlay: OpenSeaMap (cartas náuticas grátis — sondas, faróis, bóias)
const seamap=L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',{
maxZoom:18,attribution:'© OpenSeaMap',opacity:.95,className:'map-tiles-seamark'
});
// Adiciona base padrão
osm.addTo(map);
// Aplica camada náutica conforme config
const provider=getChartProvider();
let nauticalLayer=null;
if(provider==='opensea'){
seamap.addTo(map);
nauticalLayer=seamap;
}else if(provider==='navionics'&&state.chartCfg?.navKey){
nauticalLayer=tryAddNavionicsLayer(map);
if(!nauticalLayer){seamap.addTo(map);nauticalLayer=seamap}
}
// Layer switcher (canto superior direito)
const baseLayers={'OSM Padrão':osm,'Satélite':sat};
const overlayLayers={'OpenSeaMap (cartas grátis)':seamap};
if(state.chartCfg?.navKey){
overlayLayers['Navionics ⚓']={addTo:m=>tryAddNavionicsLayer(m),removeFrom:m=>{}};
}
L.control.layers(baseLayers,overlayLayers,{position:'topright',collapsed:true}).addTo(map);
return map;
}
let _navionicsScriptLoaded=null;
function loadNavionicsScript(){
if(_navionicsScriptLoaded)return _navionicsScriptLoaded;
_navionicsScriptLoaded=new Promise((resolve,reject)=>{
const s=document.createElement('script');
s.src='https://webapiv2.navionics.com/dist/webapi/webapi.min.js';
s.async=true;
s.onload=()=>resolve(window.JNC);
s.onerror=()=>reject(new Error('Navionics script failed'));
document.head.appendChild(s);
});
return _navionicsScriptLoaded;
}
function tryAddNavionicsLayer(map){
const key=state.chartCfg?.navKey;
if(!key)return null;
loadNavionicsScript().then(JNC=>{
if(!JNC?.Leaflet?.NavionicsOverlay)return;
try{
const overlay=new JNC.Leaflet.NavionicsOverlay({
navKey:key,
chartType:JNC.NAVIONICS_CHARTS.NAUTICAL,
isTransparent:true,logoPayoff:true,zIndex:5,
});
overlay.addTo(map);
console.log('[navionics] overlay added');
}catch(e){console.warn('[navionics] add failed',e.message)}
}).catch(e=>console.warn('[navionics] load failed',e.message));
return {addTo:()=>{},removeFrom:()=>{}}; // placeholder pra layer control
}
const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3';
let pendingFilter='active';
@ -2836,7 +2944,7 @@ 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)}
if(!trackingMap){trackingMap=L.map('tracking-map').setView([0,0],2);addMapLayers(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();
@ -3516,7 +3624,7 @@ function openMapModal(tid){
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);
addMapLayers(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]})}
@ -3913,7 +4021,7 @@ function updateAnchorUI(){
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)}
if(!anchorMap){anchorMap=L.map('anchor-map').setView([anchorWatch.anchorPos.lat,anchorWatch.anchorPos.lng],17);addMapLayers(anchorMap)}
setTimeout(()=>anchorMap.invalidateSize(),100);
drawAnchorOnMap();
document.getElementById('aw-radius-slider').value=anchorWatch.radius;
@ -5320,7 +5428,7 @@ function openAnchorHistoryMap(id){
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);
addMapLayers(historyMap);
const a=[h.anchorPos.lat,h.anchorPos.lng];
const sp=h.swingPos||h.anchorPos;
const s=[sp.lat,sp.lng];
@ -5575,6 +5683,119 @@ function bindWeatherInputs(){
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>`;
// bind charts cfg também (mesmo fluxo abrir aba)
bindChartInputs();
}
function bindChartInputs(){
const p=document.getElementById('chart-provider');
const k=document.getElementById('chart-nav-key');
const st=document.getElementById('chart-status');
if(!p||!k||!st)return;
p.value=state.chartCfg?.provider||'opensea';
k.value=state.chartCfg?.navKey||'';
if(state.chartCfg?.provider==='navionics'&&state.chartCfg?.navKey){
st.innerHTML='<span style="color:var(--m-ok,#10b981)">Navionics ativo · cartas oficiais</span>';
}else if(state.chartCfg?.provider==='opensea'||!state.chartCfg?.provider){
st.innerHTML='<span style="color:var(--m-text-mid,#b3c5d6)">OpenSeaMap ativo · cartas náuticas grátis</span>';
}else{
st.innerHTML='<span style="color:var(--m-text-soft,#7d97ad)">Apenas OSM · sem overlay náutico</span>';
}
}
function saveChartCfg(){
state.chartCfg={
provider:document.getElementById('chart-provider').value,
navKey:document.getElementById('chart-nav-key').value.trim(),
};
saveState();
bindChartInputs();
}
function testChartCfg(){
saveChartCfg();
toast('Cartas salvas — abra um mapa pra ver');
}
// ============ EXPORT GPX para OpenCPN ============
function gpxEscape(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]))}
function exportOpenCPN(){
const now=new Date().toISOString();
let gpx=`<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Shivao Diario de Bordo" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Shivao — ${gpxEscape(activeBoat()?.name||'Diário de Bordo')}</name>
<desc>Export consolidado de viagens, fundeios e zonas</desc>
<time>${now}</time>
</metadata>
`;
// Waypoints: anchorages históricos
const anchors=state.anchorHistory||[];
for(const a of anchors){
if(!a.anchorPos)continue;
gpx+=` <wpt lat="${a.anchorPos.lat.toFixed(6)}" lon="${a.anchorPos.lng.toFixed(6)}">
<name>⚓ ${gpxEscape(a.label||'Fundeio '+new Date(a.startedAt||Date.now()).toLocaleDateString('pt-BR'))}</name>
<desc>Raio ${a.radius||'?'}m · ${a.duration?Math.round(a.duration/60000)+'min':'desconhecido'}</desc>
<sym>Anchor</sym>
<type>anchorage</type>
</wpt>
`;
}
// Tracks: cada viagem com pontos GPS
const trips=(state.trips||[]).filter(t=>t.track&&t.track.length>0);
for(const t of trips){
gpx+=` <trk>
<name>${gpxEscape(t.destination||'Travessia '+new Date(t.dateStart||Date.now()).toLocaleDateString('pt-BR'))}</name>
<desc>${gpxEscape((t.notes||'').slice(0,200))}</desc>
<trkseg>
`;
for(const pt of t.track){
const lat=pt.lat||pt[0];
const lng=pt.lng||pt[1];
const ts=pt.t||pt.ts;
if(!lat||!lng)continue;
gpx+=` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}">${ts?`<time>${new Date(ts).toISOString()}</time>`:''}</trkpt>
`;
}
gpx+=` </trkseg>
</trk>
`;
}
// Routes: zonas (forbidden/attention) como rotas fechadas
const zones=state.zones||[];
for(const z of zones){
if(!z.center||!z.radius)continue;
// Aproxima círculo como polígono de 16 pontos
const points=[];
for(let i=0;i<=16;i++){
const angle=(i/16)*2*Math.PI;
const dLat=(z.radius/111000)*Math.cos(angle);
const dLng=(z.radius/(111000*Math.cos(z.center.lat*Math.PI/180)))*Math.sin(angle);
points.push([z.center.lat+dLat,z.center.lng+dLng]);
}
gpx+=` <rte>
<name>${z.kind==='forbidden'?'⛔ ':'⚠ '}${gpxEscape(z.name||'Zona')}</name>
<desc>${z.kind} · raio ${Math.round(z.radius)}m</desc>
`;
for(const [lat,lng] of points){
gpx+=` <rtept lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"></rtept>
`;
}
gpx+=` </rte>
`;
}
gpx+=`</gpx>`;
// Download
const blob=new Blob([gpx],{type:'application/gpx+xml'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;
a.download=`shivao-${new Date().toISOString().slice(0,10)}.gpx`;
document.body.appendChild(a);a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
toast(`GPX gerado · ${trips.length} tracks · ${anchors.length} fundeios · ${zones.length} zonas`);
}
function saveWeatherKey(){
@ -6066,7 +6287,7 @@ function openZoneEditor(id){
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);
addMapLayers(zoneMap);
// mostrar zonas existentes (exceto a editada)
state.zones.forEach(z=>{
if(editingZone&&z.id===editingZone.id)return;

View file

@ -347,7 +347,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
});
// Atalho: /apk redireciona pra última APK release no Forgejo
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.7.1/Shivao-v1.7.1.apk';
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.8.0/Shivao-v1.8.0.apk';
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)