Files

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();
};