918 lines
38 KiB
JavaScript
918 lines
38 KiB
JavaScript
'use strict';
|
|
/* Main app logic: Qt bridge, GPS readout, waypoints, routes, navigation. */
|
|
|
|
// ── Modo de iluminación ────────────────────────────────────────────────────
|
|
function setMode(mode) {
|
|
const modes = ['night','dusk','day','dayplus'];
|
|
if (!modes.includes(mode)) return;
|
|
// Aplica al html element (igual que ECDIS)
|
|
if (mode === 'day') {
|
|
document.documentElement.removeAttribute('data-mode');
|
|
} else {
|
|
document.documentElement.setAttribute('data-mode', mode);
|
|
}
|
|
// Marca botón activo
|
|
modes.forEach(m => {
|
|
const btn = document.getElementById('mode-' + m);
|
|
if (btn) btn.classList.toggle('active', m === mode);
|
|
});
|
|
try { localStorage.setItem('gps-mode', mode); } catch(e) {}
|
|
// Redibujar skyplot con la paleta del nuevo modo
|
|
if (typeof SkyPlot !== 'undefined') SkyPlot.redraw();
|
|
|
|
// ── Capas OSM + fondo canvas ─────────────────────────────────────────────
|
|
// ECDIS correcto: las ayudas IALA NO se filtran — solo el fondo/OSM se oscurece.
|
|
// El filtro CSS en #map fue eliminado (bug Qt5 WebEngine). En cambio:
|
|
// · osmLayer.opacity → controla visibilidad de tiles OSM
|
|
// · #map background → color de océano base en modo oscuro
|
|
var _osmOp = { night: 0.12, dusk: 0.38, day: 0.82, dayplus: 0.90 };
|
|
var _mapBg = { night: '#0a1018', dusk: '#101c30', day: '#a8c8e8', dayplus: '#b8d8f0' };
|
|
if (typeof GPSMap !== 'undefined' && GPSMap.setOsmOpacity) {
|
|
GPSMap.setOsmOpacity(_osmOp[mode] != null ? _osmOp[mode] : 0.82);
|
|
GPSMap.setMapBackground(_mapBg[mode]);
|
|
}
|
|
|
|
// ── Paleta S-52 de capas ENC ─────────────────────────────────────────────
|
|
// Recolorea DEPARE/LNDARE/DEPCNT según modo — ayudas IALA nunca se tocan.
|
|
if (typeof ChartLayer !== 'undefined' && ChartLayer.setChartMode) {
|
|
var encMode = mode === 'dayplus' ? 'day' : mode === 'day' ? 'day-std' : mode;
|
|
ChartLayer.setChartMode(encMode);
|
|
}
|
|
}
|
|
|
|
// Restaura modo al cargar (localStorage puede fallar en file://)
|
|
(function(){
|
|
try {
|
|
const saved = localStorage.getItem('gps-mode') || 'day';
|
|
setMode(saved);
|
|
} catch(e) {
|
|
setMode('day');
|
|
}
|
|
})();
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
let _fix = {};
|
|
let _waypoints = []; // [{id,name,lat,lon,notes,mark_type}]
|
|
let _routes = []; // [{id,name,wpt_ids}]
|
|
let _marks = []; // [{id,name,lat,lon,mark_type}] — marcas POI (no son WPTs de ruta)
|
|
let _navWpt = null; // active go-to waypoint
|
|
let _autoCenter = false;
|
|
let _chartLoadPending = false; // trigger chart load on first GPS fix
|
|
let _pendingMarcaType = null; // tipo de marca seleccionado en el modal, pendiente de click en mapa
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
function _fmtDM(deg, hPos, hNeg) {
|
|
if (deg == null) return '--°--\'-.--';
|
|
const h = deg >= 0 ? hPos : hNeg;
|
|
const a = Math.abs(deg);
|
|
const d = Math.floor(a);
|
|
const m = (a - d) * 60;
|
|
return `${d}°${m.toFixed(3)}'${h}`;
|
|
}
|
|
|
|
function _fmtNum(v, dec, unit = '') {
|
|
return v != null && !isNaN(v) ? Number(v).toFixed(dec) + unit : '--';
|
|
}
|
|
|
|
function _bearingTo(lat1, lon1, lat2, lon2) {
|
|
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
|
|
const Δλ = (lon2 - lon1) * Math.PI/180;
|
|
const y = Math.sin(Δλ) * Math.cos(φ2);
|
|
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
|
return (Math.atan2(y, x) * 180/Math.PI + 360) % 360;
|
|
}
|
|
|
|
function _distNM(lat1, lon1, lat2, lon2) {
|
|
const R = 3440.065; // NM
|
|
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
|
|
const Δφ = (lat2 - lat1) * Math.PI/180;
|
|
const Δλ = (lon2 - lon1) * Math.PI/180;
|
|
const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
}
|
|
|
|
const FIX_NAMES = ['NO FIX','GPS','DGPS','PPS','RTK','Float RTK','DR','Manual','Simul.','WAAS'];
|
|
const FIX_MODE = ['','','2D','3D'];
|
|
|
|
// ── GPS message handler (called from bridge.js via gpsMessage signal) ──────
|
|
window.handleGPSMsg = function(msg) {
|
|
if (msg.type === 'connected') {
|
|
document.getElementById('lbl-port').textContent = msg.port;
|
|
document.getElementById('dot-gps').className = 'status-dot dot-ok';
|
|
} else if (msg.type === 'no_port') {
|
|
document.getElementById('lbl-port').textContent = 'NO GPS';
|
|
document.getElementById('dot-gps').className = 'status-dot';
|
|
} else if (msg.type === 'disconnected' || msg.type === 'error') {
|
|
document.getElementById('lbl-port').textContent =
|
|
msg.type === 'error' ? (msg.msg || 'ERROR') : 'DISCONNECTED';
|
|
document.getElementById('dot-gps').className = 'status-dot dot-err';
|
|
} else if (msg.type === 'position' || msg.type === 'rmc') {
|
|
_updateFix(msg);
|
|
} else if (msg.type === 'satellites') {
|
|
SkyPlot.update(msg.sats);
|
|
} else if (msg.type === 'dop') {
|
|
_updateDOP(msg);
|
|
} else if (msg.type === 'raw') {
|
|
_appendNMEA(msg.sentence);
|
|
} else if (msg.type === 'sensor') {
|
|
if (typeof window.handleSensorMsg === 'function') window.handleSensorMsg(msg);
|
|
}
|
|
};
|
|
|
|
// ── GPS readout ────────────────────────────────────────────────────────────
|
|
function _updateFix(msg) {
|
|
Object.assign(_fix, msg);
|
|
const lat = _fix.lat, lon = _fix.lon;
|
|
const fq = _fix.fix_quality ?? 0;
|
|
|
|
document.getElementById('r-lat').textContent = _fmtDM(lat, 'N', 'S');
|
|
document.getElementById('r-lon').textContent = _fmtDM(lon, 'E', 'W');
|
|
document.getElementById('r-sog').textContent = _fmtNum(_fix.sog, 1, ' kn');
|
|
document.getElementById('r-cog').textContent = _fmtNum(_fix.cog, 1, '°');
|
|
document.getElementById('r-cogm').textContent= _fmtNum(_fix.cog_m, 1, '°M');
|
|
document.getElementById('r-magvar').textContent = _fix.magvar != null
|
|
? (_fix.magvar >= 0 ? '+' : '') + _fix.magvar.toFixed(1) + '°' : '--';
|
|
document.getElementById('r-alt').textContent = _fmtNum(_fix.altitude, 1, ' m');
|
|
document.getElementById('r-hdop').textContent= _fmtNum(_fix.hdop, 1);
|
|
document.getElementById('r-sats').textContent= _fix.satellites ?? '--';
|
|
|
|
// Fix badge — colores: rojo=sin fix, verde=GPS, esmeralda=DGPS, cian=RTK
|
|
const badge = document.getElementById('fix-badge');
|
|
const fixName = FIX_NAMES[fq] || `FIX ${fq}`;
|
|
badge.textContent = fixName;
|
|
var fixCls = 'fix-none';
|
|
if (fq === 0) fixCls = 'fix-none';
|
|
else if (fq === 2 || fq === 9) fixCls = 'fix-dgps'; // DGPS / WAAS
|
|
else if (fq >= 4) fixCls = 'fix-great'; // RTK / Float RTK
|
|
else fixCls = 'fix-ok'; // GPS normal (fq 1,3)
|
|
badge.className = 'fix-badge ' + fixCls;
|
|
|
|
// Map update
|
|
if (lat != null && lon != null && fq > 0) {
|
|
GPSMap.update(lat, lon, _fix.cog || 0, _fix.sog || 0);
|
|
if (_autoCenter) GPSMap.centerOnGPS();
|
|
_updateNavDisplay();
|
|
// Primer fix GPS: cargar cartas para la posición actual.
|
|
// Backup por si moveend llega antes de que _attachLayers registre el handler.
|
|
if (_chartLoadPending && typeof ChartLayer !== 'undefined') {
|
|
_chartLoadPending = false;
|
|
setTimeout(function() { ChartLayer.loadAll(); }, 500);
|
|
}
|
|
}
|
|
|
|
// Sky plot
|
|
if (msg.sats) SkyPlot.update(msg.sats);
|
|
}
|
|
|
|
function _updateDOP(msg) {
|
|
document.getElementById('r-hdop').textContent = _fmtNum(msg.hdop, 1);
|
|
document.getElementById('r-vdop').textContent = _fmtNum(msg.vdop, 1);
|
|
document.getElementById('r-pdop').textContent = _fmtNum(msg.pdop, 1);
|
|
document.getElementById('r-fix').textContent = FIX_MODE[msg.fix_mode] || '--';
|
|
}
|
|
|
|
// ── UTC clock ──────────────────────────────────────────────────────────────
|
|
setInterval(() => {
|
|
const now = new Date();
|
|
const utc = now.toISOString().replace('T',' ').substring(0,19) + ' UTC';
|
|
document.getElementById('utc-clock').textContent = utc;
|
|
}, 1000);
|
|
|
|
// ── Navigation (go-to waypoint) ────────────────────────────────────────────
|
|
function startNav(wpt) {
|
|
_navWpt = wpt;
|
|
GPSMap.setActiveNav(wpt);
|
|
_renderWaypoints();
|
|
document.getElementById('nav-section').style.display = '';
|
|
document.getElementById('nav-wpt-name').textContent = wpt.name;
|
|
_updateNavDisplay();
|
|
}
|
|
|
|
function stopNav() {
|
|
_navWpt = null;
|
|
GPSMap.setActiveNav(null);
|
|
_renderWaypoints();
|
|
document.getElementById('nav-section').style.display = 'none';
|
|
}
|
|
|
|
function _updateNavDisplay() {
|
|
if (!_navWpt || _fix.lat == null) return;
|
|
const brg = _bearingTo(_fix.lat, _fix.lon, _navWpt.lat, _navWpt.lon);
|
|
const dist = _distNM(_fix.lat, _fix.lon, _navWpt.lat, _navWpt.lon);
|
|
const sog = _fix.sog || 0;
|
|
const eta = sog > 0.1 ? (dist / sog * 60).toFixed(0) + ' min' : '--';
|
|
|
|
document.getElementById('nav-brg').textContent = brg.toFixed(1) + '°';
|
|
document.getElementById('nav-dist').textContent = dist.toFixed(2) + ' NM';
|
|
document.getElementById('nav-eta').textContent = eta;
|
|
document.getElementById('nav-xte').textContent = '--';
|
|
}
|
|
|
|
// ── Waypoints ──────────────────────────────────────────────────────────────
|
|
async function _loadWaypoints() {
|
|
if (!window.py) return;
|
|
var all = JSON.parse(await _py('get_waypoints'));
|
|
// Separar WPTs de navegación (sin mark_type) de marcas POI (con mark_type)
|
|
_waypoints = all.filter(w => !w.mark_type);
|
|
_marks = all.filter(w => w.mark_type);
|
|
_renderWaypoints();
|
|
_renderMapWaypoints();
|
|
if (GPSMap && GPSMap.renderMarks) GPSMap.renderMarks(_marks);
|
|
_renderMarksList();
|
|
}
|
|
|
|
function _renderWaypoints() {
|
|
const el = document.getElementById('wpt-list');
|
|
if (!el) return;
|
|
if (!_waypoints.length) {
|
|
el.innerHTML = '<div class="empty-list">No waypoints saved</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = _waypoints.map(w => {
|
|
const dist = (_fix.lat != null) ? _distNM(_fix.lat, _fix.lon, w.lat, w.lon).toFixed(1) + ' NM' : '';
|
|
const brg = (_fix.lat != null) ? _bearingTo(_fix.lat, _fix.lon, w.lat, w.lon).toFixed(0) + '°' : '';
|
|
const isActive = _navWpt && _navWpt.id === w.id;
|
|
return `
|
|
<div class="list-item ${isActive ? 'item-active' : ''} ${w.locked ? 'item-locked' : ''}">
|
|
<div class="item-name">${w.name}</div>
|
|
<div class="item-sub">${_fmtDM(w.lat,'N','S')} ${_fmtDM(w.lon,'E','W')}</div>
|
|
${dist ? `<div class="item-nav">${brg} ${dist}</div>` : ''}
|
|
<div class="item-btns">
|
|
<button class="icon-btn" onclick='startNav(${JSON.stringify(w)})' title="Ir a">▶</button>
|
|
<button class="icon-btn" onclick='openWptModal(${JSON.stringify(w)})' title="Editar">✎</button>
|
|
<button class="icon-btn icon-lock ${w.locked ? 'locked' : ''}" onclick='toggleWptLock("${w.id}")' title="${w.locked ? 'Desbloquear' : 'Bloquear'}">${w.locked ? '🔒' : '🔓'}</button>
|
|
<button class="icon-btn icon-del" onclick='deleteWpt("${w.id}")' title="Eliminar">✕</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function _renderMarksList() {
|
|
var el = document.getElementById('mark-list');
|
|
if (!el) return;
|
|
if (!_marks.length) {
|
|
el.innerHTML = '<div class="empty-list">No hay marcas guardadas</div>';
|
|
return;
|
|
}
|
|
var MARK_DEFS = { fishing:'🎣', marina:'⚓', fuel:'⛽', restaurant:'🍴', dive:'🤿',
|
|
anchorage:'🚢', beach:'🏖️', ramp:'🚤', repair:'🔧', hospital:'🏥',
|
|
customs:'🛂', danger:'⚠️', hotel:'🏨', poi:'📍' };
|
|
el.innerHTML = _marks.map(function(m) {
|
|
var emoji = MARK_DEFS[m.mark_type] || '📍';
|
|
return '<div class="list-item ' + (m.locked ? 'item-locked' : '') + '" style="display:flex;align-items:center;gap:6px">' +
|
|
'<span class="mark-item-icon">' + emoji + '</span>' +
|
|
'<div class="mark-item-body">' +
|
|
'<div class="item-name">' + m.name + '</div>' +
|
|
'<div class="item-sub">' + _fmtDM(m.lat,'N','S') + ' ' + _fmtDM(m.lon,'E','W') + '</div>' +
|
|
'</div>' +
|
|
'<div class="item-btns">' +
|
|
'<button class="icon-btn" onclick=\'openMarkModal(' + JSON.stringify(m) + ')\' title="Editar">✎</button>' +
|
|
'<button class="icon-btn icon-lock ' + (m.locked ? 'locked' : '') + '" onclick=\'toggleMarkLock("' + m.id + '")\' title="' + (m.locked ? 'Desbloquear' : 'Bloquear') + '">' + (m.locked ? '🔒' : '🔓') + '</button>' +
|
|
'<button class="icon-btn icon-del" onclick=\'deleteMark("' + m.id + '")\' title="Eliminar">✕</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function _renderMapWaypoints() {
|
|
GPSMap.renderWaypoints(_waypoints);
|
|
const wptMap = Object.fromEntries(_waypoints.map(w => [w.id, w]));
|
|
GPSMap.renderRoutes(_routes, wptMap);
|
|
}
|
|
|
|
window.onWptMapClick = function(wpt) {
|
|
openWptModal(wpt);
|
|
};
|
|
|
|
// ── Map click handlers (draw modes) ───────────────────────────────────────
|
|
let _routeDraftPoints = [];
|
|
let _routeDraftName = '';
|
|
|
|
window.onMapClickWpt = async function(lat, lon) {
|
|
const n = _waypoints.length + 1;
|
|
openWptModal({ lat, lon, name: `WPT ${String(n).padStart(3,'0')}` });
|
|
};
|
|
|
|
window.onMapClickRoute = async function(lat, lon, idx) {
|
|
if (!window.py) return;
|
|
const name = `RP${String(idx).padStart(2,'0')}`;
|
|
const data = { name, lat, lon, notes: 'Route point' };
|
|
const saved = JSON.parse(await _py('save_waypoint', JSON.stringify(data)));
|
|
_routeDraftPoints.push(saved);
|
|
await _loadWaypoints();
|
|
const btn = document.getElementById('btn-draw-route');
|
|
if (btn) btn.textContent = `✔ RTE (${idx})`;
|
|
};
|
|
|
|
window.onMapDblClickRoute = async function(coords) {
|
|
if (_routeDraftPoints.length < 2) {
|
|
alert('Need at least 2 points for a route');
|
|
_cancelDrawMode();
|
|
return;
|
|
}
|
|
const name = prompt('Route name:', _routeDraftName || `Route ${_routes.length + 1}`);
|
|
if (!name) { _cancelDrawMode(); return; }
|
|
const wpt_ids = _routeDraftPoints.map(p => p.id);
|
|
await _py('save_route', JSON.stringify({ name, wpt_ids }));
|
|
_routeDraftPoints = [];
|
|
_routeDraftName = '';
|
|
_cancelDrawMode();
|
|
await _loadRoutes();
|
|
lpTab('rte'); // muestra el tab de rutas
|
|
};
|
|
|
|
// ── Draw mode toolbar ──────────────────────────────────────────────────────
|
|
function _cancelDrawMode() {
|
|
GPSMap.cancelDraw();
|
|
_routeDraftPoints = [];
|
|
const btnWpt = document.getElementById('btn-draw-wpt');
|
|
const btnRte = document.getElementById('btn-draw-route');
|
|
if (btnWpt) { btnWpt.classList.remove('active'); btnWpt.textContent = '✚ WPT'; }
|
|
if (btnRte) { btnRte.classList.remove('active'); btnRte.textContent = '✚ RTE'; }
|
|
}
|
|
|
|
function toggleDrawWpt() {
|
|
if (GPSMap.getDrawMode() === 'wpt') { _cancelDrawMode(); return; }
|
|
_cancelDrawMode();
|
|
GPSMap.setDrawMode('wpt');
|
|
const btn = document.getElementById('btn-draw-wpt');
|
|
if (btn) { btn.classList.add('active'); btn.textContent = '✕ WPT'; }
|
|
}
|
|
|
|
function toggleDrawRoute() {
|
|
if (GPSMap.getDrawMode() === 'route') {
|
|
if (_routeDraftPoints.length >= 2) {
|
|
window.onMapDblClickRoute([]);
|
|
} else {
|
|
_cancelDrawMode();
|
|
}
|
|
return;
|
|
}
|
|
_cancelDrawMode();
|
|
_routeDraftPoints = [];
|
|
GPSMap.setDrawMode('route');
|
|
const btn = document.getElementById('btn-draw-route');
|
|
if (btn) { btn.classList.add('active'); btn.textContent = '✔ RTE (0)'; }
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape' && GPSMap.getDrawMode() !== 'none') _cancelDrawMode();
|
|
});
|
|
|
|
// ── Waypoint modal ─────────────────────────────────────────────────────────
|
|
window.openWptModal = function(wpt = {}) {
|
|
document.getElementById('wpt-id').value = wpt.id || '';
|
|
document.getElementById('wpt-name').value = wpt.name || '';
|
|
document.getElementById('wpt-lat').value = wpt.lat != null ? wpt.lat.toFixed(6) : '';
|
|
document.getElementById('wpt-lon').value = wpt.lon != null ? wpt.lon.toFixed(6) : '';
|
|
document.getElementById('wpt-notes').value = wpt.notes || '';
|
|
showModal('modal-wpt');
|
|
};
|
|
|
|
function addWptFromGPS() {
|
|
if (_fix.lat == null) { alert('No GPS fix'); return; }
|
|
const n = _waypoints.length + 1;
|
|
openWptModal({ lat: _fix.lat, lon: _fix.lon, name: `WPT ${String(n).padStart(3,'0')}` });
|
|
}
|
|
|
|
function addWptManual() { openWptModal(); }
|
|
|
|
async function saveWpt() {
|
|
const name = document.getElementById('wpt-name').value.trim();
|
|
const lat = parseFloat(document.getElementById('wpt-lat').value);
|
|
const lon = parseFloat(document.getElementById('wpt-lon').value);
|
|
if (!name || isNaN(lat) || isNaN(lon)) { alert('Name, lat and lon are required'); return; }
|
|
const data = {
|
|
id: document.getElementById('wpt-id').value || undefined,
|
|
name, lat, lon,
|
|
notes: document.getElementById('wpt-notes').value.trim(),
|
|
};
|
|
await _py('save_waypoint', JSON.stringify(data));
|
|
closeModal();
|
|
await _loadWaypoints();
|
|
}
|
|
|
|
async function deleteWpt(id) {
|
|
if (!confirm('Delete waypoint?')) return;
|
|
window.py.delete_waypoint(id); // void — fire and forget
|
|
if (_navWpt && _navWpt.id === id) stopNav();
|
|
await _loadWaypoints();
|
|
}
|
|
|
|
// ── Routes ─────────────────────────────────────────────────────────────────
|
|
async function _loadRoutes() {
|
|
if (!window.py) return;
|
|
_routes = JSON.parse(await _py('get_routes'));
|
|
_renderRoutes();
|
|
_renderMapWaypoints();
|
|
}
|
|
|
|
function _renderRoutes() {
|
|
const el = document.getElementById('route-list');
|
|
if (!el) return;
|
|
if (!_routes.length) {
|
|
el.innerHTML = '<div class="empty-list">No routes saved</div>';
|
|
return;
|
|
}
|
|
const wptMap = Object.fromEntries(_waypoints.map(w => [w.id, w]));
|
|
el.innerHTML = _routes.map(r => {
|
|
const wpts = (r.wpt_ids || []).map(id => wptMap[id]?.name || id).join(' → ');
|
|
return `
|
|
<div class="list-item">
|
|
<div class="item-name">${r.name}</div>
|
|
<div class="item-sub">${wpts}</div>
|
|
<div class="item-btns">
|
|
<button class="icon-btn icon-del" onclick='deleteRoute("${r.id}")' title="Delete">✕</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function newRoute() {
|
|
document.getElementById('rte-name').value = '';
|
|
document.getElementById('rte-id').value = '';
|
|
const sel = document.getElementById('rte-wpt-selector');
|
|
sel.innerHTML = _waypoints.map(w => `
|
|
<label class="rte-wpt-row">
|
|
<input type="checkbox" value="${w.id}"> ${w.name}
|
|
<span class="rte-wpt-sub">${_fmtDM(w.lat,'N','S')} ${_fmtDM(w.lon,'E','W')}</span>
|
|
</label>`).join('');
|
|
showModal('modal-route');
|
|
}
|
|
|
|
async function saveRoute() {
|
|
const name = document.getElementById('rte-name').value.trim();
|
|
if (!name) { alert('Name required'); return; }
|
|
const checks = document.querySelectorAll('#rte-wpt-selector input:checked');
|
|
const wpt_ids = [...checks].map(c => c.value);
|
|
if (wpt_ids.length < 2) { alert('Select at least 2 waypoints'); return; }
|
|
const data = {
|
|
id: document.getElementById('rte-id').value || undefined,
|
|
name, wpt_ids,
|
|
};
|
|
await _py('save_route', JSON.stringify(data));
|
|
closeModal();
|
|
await _loadRoutes();
|
|
}
|
|
|
|
async function deleteRoute(id) {
|
|
if (!confirm('Delete route?')) return;
|
|
window.py.delete_route(id); // void
|
|
await _loadRoutes();
|
|
}
|
|
|
|
// ── NMEA log ───────────────────────────────────────────────────────────────
|
|
const _nmeaBuf = [];
|
|
function _escapeHtml(str) {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function _appendNMEA(line) {
|
|
const color = line.startsWith('$GP') || line.startsWith('$GN') ? '#4ade80'
|
|
: line.startsWith('$GL') ? '#f87171'
|
|
: line.startsWith('$GA') ? '#34d399'
|
|
: '#94a3b8';
|
|
// Escape the raw NMEA sentence before inserting into innerHTML to prevent XSS
|
|
_nmeaBuf.push(`<span style="color:${color}">${_escapeHtml(line)}</span>`);
|
|
if (_nmeaBuf.length > 200) _nmeaBuf.shift();
|
|
// Actualiza en tiempo real solo si el tab NMEA está activo
|
|
const nmeaContent = document.getElementById('lp-nmea');
|
|
if (nmeaContent && !nmeaContent.classList.contains('hidden')) {
|
|
const el = document.getElementById('nmea-log');
|
|
if (el) { el.innerHTML = _nmeaBuf.join('\n'); el.scrollTop = el.scrollHeight; }
|
|
}
|
|
}
|
|
|
|
// ── Port connect modal ─────────────────────────────────────────────────────
|
|
async function showConnectModal() {
|
|
if (!window.py) { alert('Bridge not ready'); return; }
|
|
const ports = JSON.parse(await _py('list_ports'));
|
|
const sel = document.getElementById('sel-port');
|
|
sel.innerHTML = ports.length
|
|
? ports.map(p => `<option value="${p.port}">${p.port} — ${p.desc}</option>`).join('')
|
|
: '<option value="">No ports found</option>';
|
|
showModal('modal-connect');
|
|
}
|
|
|
|
function doConnect() {
|
|
const port = document.getElementById('sel-port').value;
|
|
const baud = parseInt(document.getElementById('sel-baud').value);
|
|
if (!port) return;
|
|
window.py.connect_gps(port, baud); // void — GPS connected msg arrives via signal
|
|
document.getElementById('lbl-port').textContent = port;
|
|
closeModal();
|
|
}
|
|
|
|
function doDisconnect() {
|
|
window.py.disconnect_gps(); // void
|
|
document.getElementById('lbl-port').textContent = 'DISCONNECTED';
|
|
document.getElementById('dot-gps').className = 'status-dot dot-err';
|
|
closeModal();
|
|
}
|
|
|
|
// ── Panel izquierdo — sistema de tabs ─────────────────────────────────────
|
|
const _LP_TABS = ['gps', 'wpt', 'rte', 'mrk', 'nmea'];
|
|
|
|
function lpTab(tab) {
|
|
try {
|
|
_LP_TABS.forEach(t => {
|
|
const content = document.getElementById('lp-' + t);
|
|
const btn = document.getElementById('lptab-' + t);
|
|
if (content) content.classList.toggle('hidden', t !== tab);
|
|
if (btn) btn.classList.toggle('active', t === tab);
|
|
});
|
|
// Acciones al abrir cada tab
|
|
if (tab === 'wpt') _renderWaypoints();
|
|
if (tab === 'rte') _renderRoutes();
|
|
if (tab === 'mrk') _renderMarksList();
|
|
if (tab === 'nmea') {
|
|
const el = document.getElementById('nmea-log');
|
|
if (el && _nmeaBuf.length) {
|
|
el.innerHTML = _nmeaBuf.join('\n');
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}
|
|
} catch(e) {
|
|
console.error('[lpTab]', e);
|
|
}
|
|
}
|
|
|
|
// Compatibilidad: si algo llama togglePanel apunta al tab equivalente
|
|
function togglePanel(panelId) {
|
|
if (panelId === 'panel-waypoints') lpTab('wpt');
|
|
else if (panelId === 'panel-routes') lpTab('rte');
|
|
else if (panelId === 'panel-nmea') lpTab('nmea');
|
|
}
|
|
|
|
// ── Modal helpers ──────────────────────────────────────────────────────────
|
|
function showModal(id) {
|
|
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
|
|
document.getElementById(id).classList.remove('hidden');
|
|
document.getElementById('modal-overlay').classList.remove('hidden');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal-overlay').classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('modal-overlay').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('modal-overlay')) closeModal();
|
|
});
|
|
|
|
// ── Track ──────────────────────────────────────────────────────────────────
|
|
async function _loadTrack() {
|
|
if (!window.py) return;
|
|
const pts = JSON.parse(await _py('get_track', 2000));
|
|
GPSMap.loadTrack(pts);
|
|
}
|
|
|
|
// ── Charts ─────────────────────────────────────────────────────────────────
|
|
let _chartCells = [];
|
|
|
|
async function showChartsModal() {
|
|
showModal('modal-charts'); // abre el modal siempre, aunque falle el refresh
|
|
await _refreshChartCells();
|
|
}
|
|
|
|
async function _refreshChartCells() {
|
|
const el = document.getElementById('chart-cell-list');
|
|
if (!window.py) {
|
|
if (el) el.innerHTML = '<div class="empty-list" style="color:#f87171">Bridge not ready — restart app</div>';
|
|
return;
|
|
}
|
|
try {
|
|
_chartCells = JSON.parse(await _py('get_chart_cells'));
|
|
_renderChartCells();
|
|
} catch(e) {
|
|
console.error('[charts] refresh failed:', e);
|
|
if (el) el.innerHTML = '<div class="empty-list" style="color:#f87171">Error: ' + e + '</div>';
|
|
}
|
|
}
|
|
|
|
function _renderChartCells() {
|
|
const el = document.getElementById('chart-cell-list');
|
|
if (!el) return;
|
|
if (!_chartCells.length) {
|
|
el.innerHTML = '<div class="empty-list">No charts installed</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = _chartCells.map(c => {
|
|
const bbox = c.bbox ? c.bbox.map(v => v.toFixed(2)).join(', ') : '--';
|
|
return `
|
|
<div class="list-item" style="min-width:0;max-width:100%;margin-bottom:4px">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
<div>
|
|
<span class="item-name">${c.id}</span>
|
|
<span style="margin-left:6px;font-size:0.7rem;color:var(--muted)">${c.features} features</span>
|
|
</div>
|
|
<div style="display:flex;gap:4px;align-items:center">
|
|
<select style="background:var(--bg3);border:1px solid var(--border);color:var(--text);font-size:0.68rem;border-radius:3px;padding:1px 4px"
|
|
onchange="setChartRegion('${c.id}', this.value)">
|
|
<option value="B" ${c.region==='B'?'selected':''}>IALA-B</option>
|
|
<option value="A" ${c.region==='A'?'selected':''}>IALA-A</option>
|
|
</select>
|
|
<button class="icon-btn icon-del" onclick="deleteChart('${c.id}')">✕</button>
|
|
</div>
|
|
</div>
|
|
<div class="item-sub" style="margin-top:2px">bbox: ${bbox}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function uploadChart() {
|
|
if (!window.py) return;
|
|
const btn = document.getElementById('btn-upload-chart');
|
|
btn.textContent = 'OPENING…'; btn.disabled = true;
|
|
try {
|
|
const res = JSON.parse(await _py('open_chart_file_dialog'));
|
|
let msg = '';
|
|
if (res.installed && res.installed.length)
|
|
msg += `Installed: ${res.installed.join(', ')}\n`;
|
|
if (res.skipped && res.skipped.length)
|
|
msg += `Already installed (skipped): ${res.skipped.join(', ')}\n`;
|
|
if (res.errors && res.errors.length)
|
|
msg += `Errors:\n` + res.errors.map(e => ` ${e.file}: ${e.error}`).join('\n');
|
|
if (!msg) msg = 'No charts installed (dialog cancelled or no valid files).';
|
|
if (res.installed && res.installed.length) {
|
|
alert(msg.trim());
|
|
await ChartLayer.reloadAll();
|
|
await _refreshChartCells();
|
|
}
|
|
} catch(e) {
|
|
alert('Upload error: ' + e);
|
|
} finally {
|
|
btn.textContent = 'UPLOAD'; btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function scanChartsPath() {
|
|
if (!window.py) return;
|
|
const inp = document.getElementById('chart-path-inp');
|
|
const path = inp.value.trim();
|
|
if (!path) { alert('Enter a folder path (e.g. E:\\ENC_Charts)'); return; }
|
|
|
|
const btn = document.getElementById('btn-scan-chart');
|
|
btn.textContent = 'SCANNING…'; btn.disabled = true;
|
|
try {
|
|
const res = JSON.parse(await _py('scan_charts_path', path));
|
|
let msg = '';
|
|
if (res.installed && res.installed.length)
|
|
msg += `Installed: ${res.installed.join(', ')}\n`;
|
|
if (res.skipped && res.skipped.length)
|
|
msg += `Already installed (skipped): ${res.skipped.join(', ')}\n`;
|
|
if (res.errors && res.errors.length)
|
|
msg += `Errors:\n` + res.errors.map(e => ` ${e.file}: ${e.error}`).join('\n');
|
|
if (!msg) msg = 'No .000 or .zip chart files found in that folder.';
|
|
alert(msg.trim());
|
|
|
|
if (res.installed && res.installed.length) {
|
|
await ChartLayer.reloadAll();
|
|
await _refreshChartCells();
|
|
}
|
|
} catch(e) {
|
|
alert('Scan error: ' + e);
|
|
} finally {
|
|
btn.textContent = 'SCAN'; btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteChart(cellId) {
|
|
if (!confirm(`Delete chart cell "${cellId}"?`)) return;
|
|
window.py.delete_chart(cellId); // void
|
|
await ChartLayer.reloadAll();
|
|
await _refreshChartCells();
|
|
}
|
|
|
|
async function setChartRegion(cellId, region) {
|
|
await _py('set_chart_region', cellId, region);
|
|
await ChartLayer.reloadAll();
|
|
}
|
|
|
|
// ── ENC layers modal (AVANZADO) ────────────────────────────────────────────
|
|
function openEncLayersModal() {
|
|
ChartLayer.setDetailLevel('advanced');
|
|
var adv = ChartLayer.getAdvLayers();
|
|
// Profundidades
|
|
document.getElementById('el-depare').checked = adv.depare;
|
|
document.getElementById('el-depcnt').checked = adv.depcnt;
|
|
document.getElementById('el-soundg').checked = adv.soundg;
|
|
// Peligros y zonas
|
|
document.getElementById('el-hazards').checked = adv.hazards;
|
|
document.getElementById('el-zones').checked = adv.zones;
|
|
// Tierra y costa
|
|
document.getElementById('el-coalne').checked = adv.coalne;
|
|
document.getElementById('el-landmask').checked = adv.landmask;
|
|
document.getElementById('el-lndare').checked = adv.lndare;
|
|
document.getElementById('el-buaare').checked = adv.buaare;
|
|
// Mapa base
|
|
document.getElementById('el-osm').checked = adv.osm;
|
|
showModal('modal-enc-layers');
|
|
}
|
|
|
|
function applyEncLayers() {
|
|
ChartLayer.setLayerVisibility({
|
|
// Profundidades
|
|
depare: document.getElementById('el-depare').checked,
|
|
depcnt: document.getElementById('el-depcnt').checked,
|
|
soundg: document.getElementById('el-soundg').checked,
|
|
// Peligros y zonas
|
|
hazards: document.getElementById('el-hazards').checked,
|
|
zones: document.getElementById('el-zones').checked,
|
|
// Tierra y costa
|
|
coalne: document.getElementById('el-coalne').checked,
|
|
landmask: document.getElementById('el-landmask').checked,
|
|
lndare: document.getElementById('el-lndare').checked,
|
|
buaare: document.getElementById('el-buaare').checked,
|
|
// Mapa base
|
|
osm: document.getElementById('el-osm').checked,
|
|
});
|
|
closeModal();
|
|
}
|
|
|
|
// ── Chart-under-cursor indicator ──────────────────────────────────────────
|
|
(function _initChartCursor() {
|
|
/* Espera a que el mapa esté listo */
|
|
function _setup() {
|
|
if (!window.GPSMap || !GPSMap.getOLMap) return setTimeout(_setup, 500);
|
|
const olMap = GPSMap.getOLMap();
|
|
const info = document.getElementById('map-chart-info');
|
|
if (!info) return;
|
|
|
|
let _hideTimer = null;
|
|
olMap.on('pointermove', function (evt) {
|
|
if (!_chartCells.length) return;
|
|
const [lon, lat] = ol.proj.toLonLat(evt.coordinate);
|
|
const hits = _chartCells.filter(function (c) {
|
|
if (!c.bbox || c.bbox.length < 4) return false;
|
|
return lon >= c.bbox[0] && lat >= c.bbox[1] && lon <= c.bbox[2] && lat <= c.bbox[3];
|
|
});
|
|
if (hits.length) {
|
|
info.textContent = '⛵ ' + hits.map(function(c){ return c.id; }).join(' · ');
|
|
info.classList.add('visible');
|
|
clearTimeout(_hideTimer);
|
|
_hideTimer = setTimeout(function(){ info.classList.remove('visible'); }, 3000);
|
|
} else {
|
|
clearTimeout(_hideTimer);
|
|
info.classList.remove('visible');
|
|
}
|
|
});
|
|
}
|
|
_setup();
|
|
})();
|
|
|
|
// ── Sensor data (compass HDG, ecosonda depth/temp) ────────────────────────
|
|
window.handleSensorMsg = function (msg) {
|
|
/* msg viene de bridge.py via gpsMessage con type:'sensor'
|
|
Formatos esperados:
|
|
HDT: {type:'sensor', src:'HDT', hdg_t: 123.4}
|
|
HDM: {type:'sensor', src:'HDM', hdg_m: 123.4}
|
|
DBT/DPT: {type:'sensor', src:'DBT', depth: 12.3}
|
|
MTW: {type:'sensor', src:'MTW', water_temp: 28.5} */
|
|
if (msg.hdg_t != null) { const e = document.getElementById('r-hdg-t'); if (e) e.textContent = msg.hdg_t.toFixed(1) + '°T'; }
|
|
if (msg.hdg_m != null) { const e = document.getElementById('r-hdg-m'); if (e) e.textContent = msg.hdg_m.toFixed(1) + '°M'; }
|
|
if (msg.depth != null) { const e = document.getElementById('r-depth'); if (e) e.textContent = msg.depth.toFixed(1) + ' m'; }
|
|
if (msg.water_temp != null) { const e = document.getElementById('r-water-temp'); if (e) e.textContent = msg.water_temp.toFixed(1) + '°C'; }
|
|
};
|
|
|
|
// ── MARCAS POI ─────────────────────────────────────────────────────────────
|
|
var _selectedMarcaType = null;
|
|
|
|
window.openMarcaModal = function() {
|
|
_selectedMarcaType = null;
|
|
document.querySelectorAll('.marca-item').forEach(function(el) { el.classList.remove('selected'); });
|
|
var hint = document.getElementById('marca-type-hint');
|
|
if (hint) hint.textContent = '— Ningún tipo seleccionado —';
|
|
var btn = document.getElementById('btn-marca-ok');
|
|
if (btn) btn.disabled = true;
|
|
showModal('modal-marca');
|
|
};
|
|
|
|
window.selectMarcaType = function(el) {
|
|
document.querySelectorAll('.marca-item').forEach(function(e) { e.classList.remove('selected'); });
|
|
el.classList.add('selected');
|
|
_selectedMarcaType = el.getAttribute('data-type');
|
|
var hint = document.getElementById('marca-type-hint');
|
|
if (hint) hint.textContent = '✔ ' + el.querySelector('.marca-label').textContent + ' seleccionado';
|
|
var btn = document.getElementById('btn-marca-ok');
|
|
if (btn) btn.disabled = false;
|
|
};
|
|
|
|
window.startMarcaDraw = function() {
|
|
if (!_selectedMarcaType) return;
|
|
_pendingMarcaType = _selectedMarcaType;
|
|
closeModal();
|
|
// Activar modo de dibujo MARCA (reutiliza el draw-mode del mapa)
|
|
GPSMap.setDrawMode('mark');
|
|
var btn = document.getElementById('btn-draw-mark');
|
|
if (btn) { btn.classList.add('active'); btn.textContent = '📍...'; }
|
|
};
|
|
|
|
// Callback cuando el usuario hace click en mapa en modo mark
|
|
window.onMapClickMark = async function(lat, lon) {
|
|
if (!window.py || !_pendingMarcaType) return;
|
|
var n = _marks.length + 1;
|
|
var typeLabels = { fishing:'PESCA', marina:'MARINA', fuel:'COMBUST', restaurant:'REST',
|
|
dive:'BUCEO', anchorage:'FONDEO', beach:'PLAYA', ramp:'RAMPA',
|
|
repair:'TALLER', hospital:'EMERG', customs:'ADUANA',
|
|
danger:'PELIGRO', hotel:'HOTEL', poi:'POI' };
|
|
var prefix = typeLabels[_pendingMarcaType] || 'MARCA';
|
|
var data = { name: prefix + String(n).padStart(2,'0'), lat, lon, mark_type: _pendingMarcaType, notes: '' };
|
|
await _py('save_waypoint', JSON.stringify(data));
|
|
await _loadWaypoints();
|
|
// Salir del modo marca
|
|
GPSMap.setDrawMode('none');
|
|
var btn = document.getElementById('btn-draw-mark');
|
|
if (btn) { btn.classList.remove('active'); btn.textContent = '📍MARCA'; }
|
|
_pendingMarcaType = null;
|
|
};
|
|
|
|
// Drag de WPT — guarda nueva posición en backend
|
|
window.onWptDrag = async function(wpt) {
|
|
if (!window.py) return;
|
|
await _py('save_waypoint', JSON.stringify(wpt));
|
|
// Re-renderizar rutas con las nuevas coordenadas
|
|
var wptMap = Object.fromEntries(_waypoints.map(w => [w.id, w]));
|
|
GPSMap.renderRoutes(_routes, wptMap);
|
|
};
|
|
|
|
// Drag de MARCA — guarda nueva posición en backend
|
|
window.onMarkDrag = async function(mark) {
|
|
if (!window.py) return;
|
|
await _py('save_waypoint', JSON.stringify(mark));
|
|
};
|
|
|
|
window.openMarkModal = function(m) {
|
|
document.getElementById('mark-id').value = m.id || '';
|
|
document.getElementById('mark-name').value = m.name || '';
|
|
document.getElementById('mark-type-val').value = m.mark_type || 'poi';
|
|
document.getElementById('mark-lat').value = m.lat != null ? m.lat.toFixed(6) : '';
|
|
document.getElementById('mark-lon').value = m.lon != null ? m.lon.toFixed(6) : '';
|
|
document.getElementById('mark-notes').value = m.notes || '';
|
|
var MARK_DEFS = { fishing:'🎣 Pesca', marina:'⚓ Marina', fuel:'⛽ Combustible',
|
|
restaurant:'🍴 Restaurante', dive:'🤿 Buceo', anchorage:'🚢 Fondeo',
|
|
beach:'🏖️ Playa', ramp:'🚤 Rampa', repair:'🔧 Taller', hospital:'🏥 Emergencia',
|
|
customs:'🛂 Aduana', danger:'⚠️ Peligro', hotel:'🏨 Hotel', poi:'📍 POI' };
|
|
document.getElementById('mark-type-display').textContent = MARK_DEFS[m.mark_type] || '📍 POI';
|
|
showModal('modal-mark');
|
|
};
|
|
|
|
window.saveMark = async function() {
|
|
if (!window.py) return;
|
|
var data = {
|
|
id: document.getElementById('mark-id').value || undefined,
|
|
name: document.getElementById('mark-name').value.trim(),
|
|
lat: parseFloat(document.getElementById('mark-lat').value),
|
|
lon: parseFloat(document.getElementById('mark-lon').value),
|
|
notes: document.getElementById('mark-notes').value.trim(),
|
|
mark_type: document.getElementById('mark-type-val').value || 'poi',
|
|
};
|
|
if (!data.name || isNaN(data.lat) || isNaN(data.lon)) { alert('Nombre, lat y lon son requeridos'); return; }
|
|
await _py('save_waypoint', JSON.stringify(data));
|
|
closeModal();
|
|
await _loadWaypoints();
|
|
};
|
|
|
|
window.deleteMark = async function(id) {
|
|
if (!confirm('¿Eliminar esta marca?')) return;
|
|
window.py.delete_waypoint(id);
|
|
await _loadWaypoints();
|
|
};
|
|
|
|
window.toggleWptLock = async function(id) {
|
|
if (!window.py) return;
|
|
var wpt = _waypoints.find(function(w) { return w.id === id; });
|
|
if (!wpt) return;
|
|
wpt.locked = wpt.locked ? 0 : 1;
|
|
await _py('save_waypoint', JSON.stringify(wpt));
|
|
await _loadWaypoints();
|
|
};
|
|
|
|
window.toggleMarkLock = async function(id) {
|
|
if (!window.py) return;
|
|
var mark = _marks.find(function(m) { return m.id === id; });
|
|
if (!mark) return;
|
|
mark.locked = mark.locked ? 0 : 1;
|
|
await _py('save_waypoint', JSON.stringify(mark));
|
|
await _loadWaypoints();
|
|
};
|
|
|
|
window.onMarkMapClick = function(mark) {
|
|
openMarkModal(mark);
|
|
};
|
|
|
|
// ── Boot (called by bridge.js once QWebChannel is ready) ──────────────────
|
|
window.bootApp = async function () {
|
|
await _loadWaypoints();
|
|
await _loadRoutes();
|
|
await _loadTrack();
|
|
// Carga inicial de cartas (mapa arranca en Miami por defecto).
|
|
// moveend handler se activa dentro de loadAll() → carga automática al navegar.
|
|
_chartLoadPending = true; // backup: re-disparar al primer fix GPS si no hay celdas
|
|
await ChartLayer.loadAll();
|
|
};
|