feat: AR-GPS initial commit — Python + JavaScript PyQt5 (standalone desktop app) + FastAPI (charts REST router) + OpenLayers (frontend map)

This commit is contained in:
2026-07-03 12:15:59 -04:00
commit 346bc1ffcb
19 changed files with 7149 additions and 0 deletions
+917
View File
@@ -0,0 +1,917 @@
'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();
};
+67
View File
@@ -0,0 +1,67 @@
'use strict';
/**
* GPS Navigator — Qt WebChannel bridge.
*
* qwebchannel.js is injected by PyQt5 at DocumentCreation time (before any
* page script runs), so QWebChannel is always available here.
*
* Exposes:
* window._py(method, arg1, ...) → Promise that resolves with the return value
* window.py → the registered bridge object (set after init)
*/
(function () {
// _py('method', arg1, arg2, ...) → Promise<result>
// Works for both void slots (resolves undefined) and value-returning slots.
window._py = function (method) {
var args = Array.prototype.slice.call(arguments, 1);
return new Promise(function (resolve) {
window.py[method].apply(window.py, args.concat([resolve]));
});
};
function _initChannel() {
if (typeof QWebChannel === 'undefined' || typeof qt === 'undefined') {
// Running in a plain browser (dev mode) — no bridge available.
console.warn('[bridge] QWebChannel not available — dev mode, no GPS bridge');
window.py = null;
// Let the app start anyway (it will show "no GPS" state)
if (typeof window.bootApp === 'function') window.bootApp();
return;
}
new QWebChannel(qt.webChannelTransport, function (channel) {
window.py = channel.objects.py;
// ── GPS messages: Python signal → JS handler ────────────────────────
// Qt queues this signal delivery from the NMEA reader thread to the
// main thread, so it always arrives in the JS event loop safely.
window.py.gpsMessage.connect(function (json_str) {
try {
var msg = JSON.parse(json_str);
if (typeof window.handleGPSMsg === 'function') {
window.handleGPSMsg(msg);
}
} catch (e) {
console.error('[bridge] gpsMessage parse error:', e, json_str);
}
});
// ── Start GPS autodetect (signal handler is now connected) ───────────
window.py.autodetect_and_start();
// ── Boot the application ─────────────────────────────────────────────
if (typeof window.bootApp === 'function') {
window.bootApp();
}
});
}
// Run after DOM + all other scripts are loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _initChannel);
} else {
_initChannel();
}
})();
File diff suppressed because it is too large Load Diff
+611
View File
@@ -0,0 +1,611 @@
'use strict';
/* Chart plotter — OpenLayers based.
Exposes window.GPSMap with methods used by app.js */
const GPSMap = (function () {
// ── Map setup ──────────────────────────────────────────────────────────────
// Fondo oceánico sólido — visible siempre aunque no haya internet.
// Las cartas ENC (land/depth) renderizan encima con zIndex 2+.
const bgLayer = new ol.layer.Tile({
source: new ol.source.XYZ({
// Tile vacío 1×1px azul — no hace peticiones de red
tileUrlFunction: function() { return null; },
}),
zIndex: 0,
});
// OSM — se carga cuando hay internet, falla silenciosamente si no hay.
const osmLayer = new ol.layer.Tile({
source: new ol.source.OSM({ crossOrigin: 'anonymous' }),
zIndex: 1,
opacity: 0.82, // leve transparencia: las cartas ENC resaltan encima
});
const trackSource = new ol.source.Vector();
const trackLayer = new ol.layer.Vector({
source: trackSource,
style: new ol.style.Style({
stroke: new ol.style.Stroke({ color: '#00c8e8cc', width: 2.5 }),
}),
zIndex: 20,
});
const wptSource = new ol.source.Vector();
const wptLayer = new ol.layer.Vector({ source: wptSource, zIndex: 22 });
const routeSource = new ol.source.Vector();
const routeLayer = new ol.layer.Vector({ source: routeSource, zIndex: 21 });
// Capa de marcas (POI: pesca, marina, buceo, etc.) — zIndex 23, encima de WPTs
const marksSource = new ol.source.Vector();
const marksLayer = new ol.layer.Vector({ source: marksSource, zIndex: 23 });
const ownSource = new ol.source.Vector();
const ownLayer = new ol.layer.Vector({ source: ownSource, zIndex: 25 });
// ── Draw mode — must be declared BEFORE ol.Map (used in layers array) ──────
// 'none' | 'wpt' | 'route'
let _drawMode = 'none';
let _routeDraftCoords = []; // [[lon,lat], ...] accumulating route
let _routeDraftFeature = null;
const routeDraftSource = new ol.source.Vector();
const routeDraftLayer = new ol.layer.Vector({
source: routeDraftSource, zIndex: 30,
style: new ol.style.Style({
stroke: new ol.style.Stroke({ color: '#fbbf24', width: 2, lineDash: [8,5] }),
image: new ol.style.Circle({
radius: 5,
fill: new ol.style.Fill({ color: '#fbbf24' }),
stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }),
}),
}),
});
const map = new ol.Map({
target: 'map',
layers: [bgLayer, osmLayer, trackLayer, routeLayer, ownLayer, wptLayer, marksLayer, routeDraftLayer],
// Default: Miami (IALA-B, área de prueba GPS). GPS auto-centra en cuanto llega fix.
view: new ol.View({ center: ol.proj.fromLonLat([-80.19, 25.77]), zoom: 12 }),
controls: [], // OL 9.x: ol.control.defaults eliminado; controles custom en toolbar
// NOTA: NO añadir pixelRatio:1 — en Qt5 WebEngine la causa del desfase de coordenadas
// era document.documentElement.style.zoom (eliminado de index.html), NO el devicePixelRatio.
});
// ── State ──────────────────────────────────────────────────────────────────
let _lat = null, _lon = null, _cog = 0, _sog = 0;
let _trackVis = true;
let _orientation = 'N'; // 'N' or 'C'
let _trackCoords = []; // [lon,lat] ordered
let _ownFeature = null;
// ── Own ship arrow ─────────────────────────────────────────────────────────
function _arrowCanvas(col = '#00d8f0', sz = 28) {
const c = document.createElement('canvas');
c.width = c.height = sz;
const ctx = c.getContext('2d');
const cx = sz / 2, cy = sz / 2, r = sz * 0.45;
ctx.save();
ctx.translate(cx, cy);
// Sombra exterior para visibilidad sobre cualquier tile OSM
ctx.shadowBlur = 5; ctx.shadowColor = 'rgba(0,0,0,0.60)';
// Arrow pointing up (north = 0°)
ctx.beginPath();
ctx.moveTo(0, -r);
ctx.lineTo(r * 0.52, r * 0.70);
ctx.lineTo(0, r * 0.28);
ctx.lineTo(-r * 0.52, r * 0.70);
ctx.closePath();
ctx.fillStyle = col;
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.restore();
return c;
}
// Cache del dataURL de la flecha — se regenera solo si cambia el COG (rotación vía style)
// Color: magenta S-52 OWNSHIP — inconfundible sobre el agua, estándar ECDIS
var _arrowDataUrl = null;
function _updateOwnShip() {
if (_lat == null || _lon == null) return;
const coord = ol.proj.fromLonLat([_lon, _lat]);
if (!_ownFeature) {
_ownFeature = new ol.Feature({ geometry: new ol.geom.Point(coord) });
ownSource.addFeature(_ownFeature);
} else {
_ownFeature.getGeometry().setCoordinates(coord);
}
// Qt5 WebEngine: img:canvas NO funciona en ol.style.Icon — usar src:dataURL.
if (!_arrowDataUrl) {
var cv = _arrowCanvas('#cc00ff', 32); // magenta S-52 OWNSHIP
try { _arrowDataUrl = cv.toDataURL('image/png'); } catch(e) { _arrowDataUrl = null; }
}
var ownStyle;
if (_arrowDataUrl) {
ownStyle = new ol.style.Style({
image: new ol.style.Icon({
src: _arrowDataUrl,
anchor: [0.5, 0.5],
anchorXUnits: 'fraction',
anchorYUnits: 'fraction',
scale: 1,
rotation: ol.math.toRadians(_cog),
rotateWithView: _orientation === 'C',
}),
});
} else {
// Fallback si toDataURL falla: triángulo con RegularShape
ownStyle = new ol.style.Style({
image: new ol.style.RegularShape({
points: 3,
radius: 10,
fill: new ol.style.Fill({ color: '#00d8f0' }),
stroke: new ol.style.Stroke({ color: '#fff', width: 2 }),
rotation: ol.math.toRadians(_cog),
}),
});
}
_ownFeature.setStyle(ownStyle);
if (_orientation === 'C') {
map.getView().setRotation(-ol.math.toRadians(_cog));
}
}
// ── Track ──────────────────────────────────────────────────────────────────
function _appendTrack(lon, lat) {
_trackCoords.push(ol.proj.fromLonLat([lon, lat]));
if (_trackCoords.length < 2) return;
trackSource.clear();
trackSource.addFeature(new ol.Feature({
geometry: new ol.geom.LineString(_trackCoords),
}));
}
function loadTrack(points) {
_trackCoords = points.map(p => ol.proj.fromLonLat([p.lon, p.lat]));
trackSource.clear();
if (_trackCoords.length >= 2) {
trackSource.addFeature(new ol.Feature({
geometry: new ol.geom.LineString(_trackCoords),
}));
}
}
// ── ECDIS helpers ─────────────────────────────────────────────────────────
function _brg(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 _nm(lat1, lon1, lat2, lon2) {
const R = 3440.065;
const φ1=lat1*Math.PI/180, φ2=lat2*Math.PI/180;
const Δφ=(lat2-lat1)*Math.PI/180, Δλ=(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));
}
// ── Iconos de MARCAS POI ──────────────────────────────────────────────────
// Pin shape: círculo con punta abajo. Anchor = [0.5, 1.0] → la punta toca la posición.
// size=34x40: viewBox "0 0 34 40", círculo cx=17 cy=17 r=15, punta en (17,40).
var _MARK_DEFS = {
fishing: { emoji: '🎣', label: 'Pesca', col: '#2eaaff' },
marina: { emoji: '⚓', label: 'Marina', col: '#00d8f0' },
fuel: { emoji: '⛽', label: 'Combustible', col: '#f8cc38' },
restaurant: { emoji: '🍴', label: 'Restaurante', col: '#ff8844' },
dive: { emoji: '🤿', label: 'Buceo', col: '#00ccaa' },
anchorage: { emoji: '⚓', label: 'Fondeo', col: '#a8d8ea' },
beach: { emoji: '🏖️', label: 'Playa', col: '#f8e87a' },
ramp: { emoji: '🚤', label: 'Rampa', col: '#88ccff' },
repair: { emoji: '🔧', label: 'Taller', col: '#b0b0b0' },
hospital: { emoji: '🏥', label: 'Emergencia', col: '#ff4444' },
hotel: { emoji: '🏨', label: 'Hotel', col: '#cc88ff' },
customs: { emoji: '🛂', label: 'Aduana', col: '#ffaa44' },
danger: { emoji: '⚠️', label: 'Peligro', col: '#ff4444' },
poi: { emoji: '📍', label: 'POI', col: '#ff6688' },
waypoint: { emoji: null, label: 'WPT Nav', col: '#00d8f0' }, // símbolo ECDIS
};
function _markSvg(markType, active) {
var d = _MARK_DEFS[markType] || _MARK_DEFS['poi'];
var col = active ? '#ffcc00' : d.col;
var pinPath = 'M17,0 C9.3,0 3,6.3 3,14 C3,22 17,40 17,40 C17,40 31,22 31,14 C31,6.3 24.7,0 17,0 Z';
var emojiSize = 15;
var svgInner = d.emoji
? ('<text x="17" y="20" text-anchor="middle" dominant-baseline="central" font-size="' + emojiSize + '" font-family="Segoe UI Emoji,Apple Color Emoji,Noto Color Emoji,sans-serif">' + d.emoji + '</text>')
: ('<line x1="17" y1="7" x2="17" y2="21" stroke="white" stroke-width="1.4"/>' +
'<line x1="10" y1="14" x2="24" y2="14" stroke="white" stroke-width="1.4"/>' +
'<circle cx="17" cy="14" r="2.5" fill="white"/>');
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="34" height="40" viewBox="0 0 34 40">' +
'<filter id="shd" x="-30%" y="-10%" width="160%" height="160%">' +
'<feDropShadow dx="0" dy="1.5" stdDeviation="1.5" flood-color="rgba(0,0,0,0.55)"/>' +
'</filter>' +
'<path d="' + pinPath + '" fill="' + col + '" stroke="white" stroke-width="1.8" filter="url(#shd)"/>' +
'<circle cx="17" cy="14" r="11" fill="rgba(0,0,0,0.18)"/>' +
svgInner +
'</svg>'
);
}
// Icono SVG ECDIS: círculo con cruz y punto central (IEC 61174 — planned position)
function _wptSvg(active) {
const col = active ? '#ffcc00' : '#00d8f0';
const fill = active ? '0.22' : '0.10';
const sw = active ? '2.2' : '1.8';
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">' +
'<circle cx="11" cy="11" r="8" fill="' + col + '" fill-opacity="' + fill + '" stroke="' + col + '" stroke-width="' + sw + '"/>' +
'<line x1="11" y1="3.5" x2="11" y2="18.5" stroke="' + col + '" stroke-width="1.3"/>' +
'<line x1="3.5" y1="11" x2="18.5" y2="11" stroke="' + col + '" stroke-width="1.3"/>' +
'<circle cx="11" cy="11" r="2" fill="' + col + '"/>' +
'</svg>'
);
}
// ── Waypoints ──────────────────────────────────────────────────────────────
let _activeNavWpt = null;
function renderWaypoints(wpts) {
wptSource.clear();
(wpts || []).forEach(w => {
const f = new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat([w.lon, w.lat])),
wpt: w,
});
f.setId('wpt_' + w.id);
const isActive = _activeNavWpt && _activeNavWpt.id === w.id;
var isLocked = !!w.locked;
var col = isActive ? '#ffcc00' : '#00d8f0';
var styles = [new ol.style.Style({
image: new ol.style.Icon({
src: _wptSvg(isActive),
anchor: [0.5, 0.5], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
scale: 1,
}),
text: new ol.style.Text({
text: w.name,
offsetX: 14, offsetY: -8,
font: 'bold 10px "JetBrains Mono", monospace',
textAlign: 'left',
fill: new ol.style.Fill({ color: col }),
stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.92)', width: 3 }),
}),
})];
if (isLocked) {
styles.push(new ol.style.Style({
text: new ol.style.Text({
text: '🔒', offsetX: 10, offsetY: 10,
font: '10px sans-serif', textAlign: 'left',
}),
}));
}
f.setStyle(styles);
wptSource.addFeature(f);
});
}
function renderRoutes(routes, wptMap) {
routeSource.clear();
(routes || []).forEach(r => {
const wpts = (r.wpt_ids || []).map(id => wptMap[id]).filter(Boolean);
if (wpts.length < 2) return;
const coords = wpts.map(w => ol.proj.fromLonLat([w.lon, w.lat]));
// Línea de ruta — cyan sólida ECDIS (IEC 61174 planned route)
const lineFeat = new ol.Feature({ geometry: new ol.geom.LineString(coords), route: r });
lineFeat.setStyle(new ol.style.Style({
stroke: new ol.style.Stroke({ color: 'rgba(0,210,240,0.88)', width: 1.8 }),
}));
routeSource.addFeature(lineFeat);
// Etiqueta de cada tramo: rumbo verdadero y distancia en NM
for (let i = 0; i < wpts.length - 1; i++) {
const w1 = wpts[i], w2 = wpts[i+1];
const c1 = ol.proj.fromLonLat([w1.lon, w1.lat]);
const c2 = ol.proj.fromLonLat([w2.lon, w2.lat]);
const mid = [(c1[0]+c2[0])/2, (c1[1]+c2[1])/2];
const brg = _brg(w1.lat, w1.lon, w2.lat, w2.lon);
const dst = _nm(w1.lat, w1.lon, w2.lat, w2.lon);
const lbl = String(Math.round(brg)).padStart(3,'0') + '°T ' + dst.toFixed(2) + ' NM';
const midFeat = new ol.Feature({ geometry: new ol.geom.Point(mid) });
midFeat.setStyle(new ol.style.Style({
text: new ol.style.Text({
text: lbl,
font: '9px "JetBrains Mono", monospace',
offsetY: -10,
fill: new ol.style.Fill({ color: '#00e8ff' }),
stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.90)', width: 3 }),
backgroundFill: new ol.style.Fill({ color: 'rgba(6,14,28,0.72)' }),
padding: [2, 5, 2, 5],
}),
image: new ol.style.RegularShape({ // pequeño rombo marcador de tramo
points: 4, radius: 3, angle: Math.PI/4,
fill: new ol.style.Fill({ color: 'rgba(0,210,240,0.80)' }),
}),
}));
routeSource.addFeature(midFeat);
}
});
}
// ── Marcas POI ────────────────────────────────────────────────────────────
function renderMarks(marks) {
marksSource.clear();
(marks || []).forEach(function(m) {
var f = new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat([m.lon, m.lat])),
mark: m,
});
f.setId('mark_' + m.id);
var isLocked = !!m.locked;
var markStyles = [new ol.style.Style({
image: new ol.style.Icon({
src: _markSvg(m.mark_type, false),
anchor: [0.5, 1.0],
anchorXUnits: 'fraction', anchorYUnits: 'fraction',
scale: 1,
}),
text: new ol.style.Text({
text: m.name,
offsetY: -44, offsetX: 0,
font: 'bold 10px "Inter", sans-serif',
textAlign: 'center',
fill: new ol.style.Fill({ color: (_MARK_DEFS[m.mark_type] || _MARK_DEFS.poi).col }),
stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.92)', width: 3 }),
}),
})];
if (isLocked) {
markStyles.push(new ol.style.Style({
text: new ol.style.Text({
text: '🔒', offsetX: 12, offsetY: -26,
font: '11px sans-serif', textAlign: 'left',
}),
}));
}
f.setStyle(markStyles);
marksSource.addFeature(f);
});
}
// ── Translate interaction — WPTs y marcas arrastrables ───────────────────
// Se inicializa tarde (en _initTranslate) porque necesita el mapa ya creado.
function _initTranslate() {
var tr = new ol.interaction.Translate({
layers: [wptLayer, marksLayer],
hitTolerance: 8,
filter: function(feature) {
var w = feature.get('wpt');
var m = feature.get('mark');
if (w && w.locked) return false;
if (m && m.locked) return false;
return true;
},
});
tr.on('translateend', function(evt) {
evt.features.forEach(function(f) {
var newLonLat = ol.proj.toLonLat(f.getGeometry().getCoordinates());
var wpt = f.get('wpt');
var mark = f.get('mark');
if (wpt) {
wpt.lon = newLonLat[0]; wpt.lat = newLonLat[1];
window.onWptDrag && window.onWptDrag(wpt);
}
if (mark) {
mark.lon = newLonLat[0]; mark.lat = newLonLat[1];
window.onMarkDrag && window.onMarkDrag(mark);
}
});
});
map.addInteraction(tr);
}
_initTranslate();
// ── COG vector ────────────────────────────────────────────────────────────
let _cogFeature = null;
function _updateCogVector() {
if (_lat == null || _sog < 0.3) {
if (_cogFeature) { ownSource.removeFeature(_cogFeature); _cogFeature = null; }
return;
}
const distM = _sog * 0.514444 * 360; // 6 min ahead
const h = _cog * Math.PI / 180;
const mLat = 111320, mLon = 111320 * Math.cos(_lat * Math.PI / 180);
const tipLon = _lon + (distM * Math.sin(h)) / mLon;
const tipLat = _lat + (distM * Math.cos(h)) / mLat;
const from = ol.proj.fromLonLat([_lon, _lat]);
const to = ol.proj.fromLonLat([tipLon, tipLat]);
if (!_cogFeature) {
_cogFeature = new ol.Feature({ geometry: new ol.geom.LineString([from, to]) });
_cogFeature.setStyle(new ol.style.Style({
stroke: new ol.style.Stroke({ color: 'rgba(204,0,255,0.80)', width: 2.0, lineDash: [7,4] }),
}));
ownSource.addFeature(_cogFeature);
} else {
_cogFeature.getGeometry().setCoordinates([from, to]);
}
}
// ── Map click ─────────────────────────────────────────────────────────────
map.on('click', evt => {
const [lon, lat] = ol.proj.toLonLat(evt.coordinate);
if (_drawMode === 'wpt') {
window.onMapClickWpt && window.onMapClickWpt(lat, lon);
return;
}
if (_drawMode === 'mark') {
window.onMapClickMark && window.onMapClickMark(lat, lon);
return;
}
if (_drawMode === 'route') {
_routeDraftCoords.push([lon, lat]);
_updateRouteDraft();
window.onMapClickRoute && window.onMapClickRoute(lat, lon, _routeDraftCoords.length);
return;
}
// Normal mode: click en WPT o MARCA para info
const f = map.forEachFeatureAtPixel(evt.pixel, f => f, { hitTolerance: 10 });
if (f && f.get('wpt')) { window.onWptMapClick && window.onWptMapClick(f.get('wpt')); }
if (f && f.get('mark')) { window.onMarkMapClick && window.onMarkMapClick(f.get('mark')); }
});
map.on('dblclick', evt => {
if (_drawMode === 'route' && _routeDraftCoords.length >= 2) {
window.onMapDblClickRoute && window.onMapDblClickRoute(_routeDraftCoords.slice());
_clearRouteDraft();
evt.preventDefault();
}
});
// ── Draft route rendering ─────────────────────────────────────────────────
function _updateRouteDraft() {
routeDraftSource.clear();
if (_routeDraftCoords.length < 1) return;
// Line
if (_routeDraftCoords.length >= 2) {
routeDraftSource.addFeature(new ol.Feature({
geometry: new ol.geom.LineString(_routeDraftCoords.map(c => ol.proj.fromLonLat(c))),
}));
}
// Dots at each waypoint
_routeDraftCoords.forEach(c => {
routeDraftSource.addFeature(new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat(c)),
}));
});
}
function _clearRouteDraft() {
_routeDraftCoords = [];
routeDraftSource.clear();
}
// ── Pointer coordinates + cursor feedback ────────────────────────────────
map.on('pointermove', evt => {
const [lon, lat] = ol.proj.toLonLat(evt.coordinate);
const el = document.getElementById('map-coords');
if (el) el.textContent = `LAT ${lat.toFixed(5)}° LON ${lon.toFixed(5)}°`;
// Crosshair cursor in draw modes
map.getTargetElement().style.cursor = _drawMode !== 'none' ? 'crosshair' : '';
});
// ── Public API ────────────────────────────────────────────────────────────
function update(lat, lon, cog, sog) {
const moved = (_lat !== lat || _lon !== lon);
_lat = lat; _lon = lon; _cog = cog || 0; _sog = sog || 0;
_updateOwnShip();
_updateCogVector();
if (moved) _appendTrack(lon, lat);
}
function centerOnGPS() {
// Push-button: centra una sola vez, sin auto-follow
if (_lat == null) return;
map.getView().animate({ center: ol.proj.fromLonLat([_lon, _lat]), duration: 300 });
}
function setOrientation(mode) {
_orientation = mode;
document.getElementById('btn-north').classList.toggle('active', mode === 'N');
document.getElementById('btn-course').classList.toggle('active', mode === 'C');
if (mode === 'N') map.getView().setRotation(0);
_updateOwnShip();
}
function toggleTrack(vis) {
_trackVis = vis !== undefined ? vis : !_trackVis;
trackLayer.setVisible(_trackVis);
const btn = document.getElementById('btn-track');
if (btn) btn.classList.toggle('active', _trackVis);
}
function clearTrackMap() {
_trackCoords = [];
trackSource.clear();
}
function setActiveNav(wpt) {
_activeNavWpt = wpt;
}
function getCenter() {
return ol.proj.toLonLat(map.getView().getCenter());
}
function getOLMap() { return map; }
function zoomIn() {
var v = map.getView();
v.animate({ zoom: Math.min((v.getZoom() || 12) + 1, 20), duration: 220 });
}
function zoomOut() {
var v = map.getView();
v.animate({ zoom: Math.max((v.getZoom() || 12) - 1, 2), duration: 220 });
}
function setDrawMode(mode) {
_drawMode = mode; // 'none' | 'wpt' | 'route'
if (mode === 'none') _clearRouteDraft();
map.getTargetElement().style.cursor = mode !== 'none' ? 'crosshair' : '';
}
function cancelDraw() {
_drawMode = 'none';
_clearRouteDraft();
map.getTargetElement().style.cursor = '';
}
function getDrawMode() { return _drawMode; }
// Control de opacidad del layer OSM — permite modo night/dusk sin filtrar las ayudas IALA.
// Las ayudas (encLayer) están en otra capa OL y no se ven afectadas por este ajuste.
function setOsmOpacity(v) {
osmLayer.setOpacity(v == null ? 0.82 : Math.max(0, Math.min(1, v)));
}
// Fondo del canvas #map — cambia el color de océano base según modo
function setMapBackground(color) {
var el = document.getElementById('map');
if (el) el.style.background = color || '#a8c8e8';
}
return {
update, centerOnGPS, setOrientation, toggleTrack,
clearTrackMap, renderWaypoints, renderRoutes, renderMarks,
setActiveNav, getCenter, loadTrack, getOLMap,
setDrawMode, cancelDraw, getDrawMode, zoomIn, zoomOut,
setOsmOpacity, setMapBackground,
marksSource,
};
})();
// Bind toolbar buttons
function centerOnGPS() { GPSMap.centerOnGPS(); }
function setOrientation(m){ GPSMap.setOrientation(m); }
function toggleTrack() { GPSMap.toggleTrack(); }
function mapZoomIn() { GPSMap.zoomIn(); }
function mapZoomOut() { GPSMap.zoomOut(); }
function clearTrack() {
if (!window.py) return;
if (!confirm('Clear the GPS track log?')) return;
window.py.clear_track();
GPSMap.clearTrackMap();
}
function addWptAtCenter() {
const [lon, lat] = GPSMap.getCenter();
window.openWptModal && window.openWptModal({ lat, lon });
}
+311
View File
@@ -0,0 +1,311 @@
'use strict';
/* Sky plot (azimuth/elevation) + SNR signal bars.
Mode-aware: lee data-mode del <html> y ajusta colores del canvas.
HiDPI-aware: escala internamente por devicePixelRatio.
Labels HTML: todo el texto se pone en divs overlay para máxima nitidez.
Usage: SkyPlot.update(sats) donde sats = [{prn,system,el,az,snr,used}, ...] */
const SkyPlot = (function () {
/* Colores base por sistema */
const SYS_COLOR = {
GPS: '#00c8e8',
GLONASS: '#f45050',
Galileo: '#28d870',
BeiDou: '#f89030',
GNSS: '#a070f8',
QZSS: '#f8c820',
};
const THEMES = {
day: {
bg: '#0a1628',
ring: '#1e3a58',
ring2: '#0e2040',
label: '#4878a8',
cardinal: '#5888b8',
snrBg: '#0a1628',
snrLabel: '#486888',
unusedAlpha: '55',
satText: '#e0f0ff',
satTextDim: '#3868a0',
snrUsedText: '#c0deff',
snrDimText: '#3868a0',
},
dusk: {
bg: '#050912',
ring: '#12202e',
ring2: '#080e18',
label: '#304060',
cardinal: '#3a5070',
snrBg: '#050912',
snrLabel: '#283848',
unusedAlpha: '44',
satText: '#b0c8e0',
satTextDim: '#283848',
snrUsedText: '#98b8d0',
snrDimText: '#283848',
},
night: {
bg: '#0e0000',
ring: '#280808',
ring2: '#180404',
label: '#582010',
cardinal: '#703020',
snrBg: '#0e0000',
snrLabel: '#401808',
unusedAlpha: '44',
override: '#c83020',
satText: '#ffb090',
satTextDim: '#582010',
snrUsedText: '#ff9878',
snrDimText: '#401808',
},
dayplus: {
bg: '#dce8f6',
ring: '#7aa4c8',
ring2: '#c4d8ec',
label: '#3060a0',
cardinal: '#003888',
snrBg: '#dce8f6',
snrLabel: '#2060a0',
unusedAlpha: '70',
satText: '#00204a',
satTextDim: '#406090',
snrUsedText: '#001030',
snrDimText: '#406090',
},
};
function _theme() {
const m = document.documentElement.getAttribute('data-mode') || 'day';
return THEMES[m] || THEMES.day;
}
/* ── HiDPI setup ──────────────────────────────────────────────────────────
Guarda dimensiones lógicas en dataset (primera vez) para evitar el
feedback loop canvas.width → offsetWidth → canvas.width... */
function _setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
if (!canvas.dataset.logicalW) {
const lw = canvas.offsetWidth || parseInt(canvas.getAttribute('width'), 10) || 258;
const lh = canvas.offsetHeight || parseInt(canvas.getAttribute('height'), 10) || 258;
canvas.dataset.logicalW = lw;
canvas.dataset.logicalH = lh;
canvas.style.width = lw + 'px';
canvas.style.height = lh + 'px';
}
const W = parseInt(canvas.dataset.logicalW, 10);
const H = parseInt(canvas.dataset.logicalH, 10);
const wPx = Math.round(W * dpr);
const hPx = Math.round(H * dpr);
if (canvas.width !== wPx || canvas.height !== hPx) {
canvas.width = wPx;
canvas.height = hPx;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, W, H };
}
/* ── Overlay HTML labels ───────────────────────────────────────────────── */
function _clearLabels(id) {
const el = document.getElementById(id);
if (el) el.innerHTML = '';
return el;
}
/* Agrega un <span> al overlay en coordenadas lógicas del canvas.
ox/oy = offset de alineación (ej. -0.5 para centrar horizontalmente).
Las coords x,y son equivalentes a las de ctx.fillText en el canvas. */
function _addLabel(overlay, text, x, y, color, opts) {
if (!overlay) return;
const s = document.createElement('span');
s.textContent = text;
s.style.left = Math.round(x) + 'px';
s.style.top = Math.round(y - 9) + 'px'; /* 9px ≈ ascent de 9px font */
s.style.color = color;
if (opts && opts.bold) s.style.fontWeight = '700';
if (opts && opts.size) s.style.fontSize = opts.size + 'px';
if (opts && opts.centerX) s.style.transform = 'translateX(-50%)';
overlay.appendChild(s);
}
/* ── Sky plot ─────────────────────────────────────────────────────────── */
function drawSky(sats) {
const canvas = document.getElementById('sky-canvas');
if (!canvas) return;
const t = _theme();
const { ctx, W, H } = _setupCanvas(canvas);
const cx = W / 2, cy = H / 2;
const R = Math.min(cx, cy) - 12;
ctx.clearRect(0, 0, W, H);
/* Fondo circular */
const bgGrad = ctx.createRadialGradient(cx, cy, R * 0.05, cx, cy, R + 10);
bgGrad.addColorStop(0, t.ring2 || t.bg);
bgGrad.addColorStop(1, t.bg);
ctx.beginPath(); ctx.arc(cx, cy, R + 10, 0, Math.PI * 2);
ctx.fillStyle = bgGrad; ctx.fill();
/* Anillos de elevación — solo formas, sin texto */
[0, 30, 60].forEach(el => {
const r = R * (1 - el / 90);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = t.ring;
ctx.lineWidth = el === 0 ? 1.2 : 0.7;
ctx.setLineDash(el === 0 ? [] : [3, 4]);
ctx.stroke();
ctx.setLineDash([]);
});
/* Líneas cardinales */
ctx.strokeStyle = t.ring; ctx.lineWidth = 0.7;
ctx.setLineDash([4, 5]);
ctx.beginPath(); ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); ctx.stroke();
ctx.setLineDash([]);
/* Satélites — solo puntos y halos */
(sats || []).forEach(s => {
if (s.el == null || s.az == null) return;
const r = R * (1 - s.el / 90);
const ang = (s.az - 90) * Math.PI / 180;
const x = cx + r * Math.cos(ang);
const y = cy + r * Math.sin(ang);
const baseCol = t.override || SYS_COLOR[s.system] || '#94a3b8';
const dotCol = s.used ? baseCol : baseCol + t.unusedAlpha;
const rad = s.used ? 8 : 5;
if (s.used) {
ctx.beginPath(); ctx.arc(x, y, rad + 5, 0, Math.PI * 2);
ctx.fillStyle = baseCol + '22'; ctx.fill();
}
ctx.beginPath(); ctx.arc(x, y, rad, 0, Math.PI * 2);
ctx.fillStyle = dotCol; ctx.fill();
if (s.used) {
ctx.strokeStyle = 'rgba(255,255,255,0.80)';
ctx.lineWidth = 1.5; ctx.stroke();
} else {
ctx.strokeStyle = baseCol + '55';
ctx.lineWidth = 0.7; ctx.stroke();
}
});
/* ── Labels HTML (nítidos) ─────────────────────────────────────────── */
const ov = _clearLabels('sky-labels');
/* Cardinales */
_addLabel(ov, 'N', cx, cy - R - 3, t.cardinal, { bold: true, size: 11, centerX: true });
_addLabel(ov, 'S', cx, cy + R + 13, t.cardinal, { bold: true, size: 11, centerX: true });
_addLabel(ov, 'E', cx + R + 4, cy + 4, t.cardinal, { bold: true, size: 11 });
_addLabel(ov, 'W', cx - R - 13, cy + 4, t.cardinal, { bold: true, size: 11 });
/* Etiquetas de elevación (30° y 60°) */
[30, 60].forEach(el => {
const r = R * (1 - el / 90);
_addLabel(ov, el + '°', cx + r + 3, cy - 1, t.label, { size: 8 });
});
/* PRN de satélites */
(sats || []).forEach(s => {
if (s.el == null || s.az == null) return;
const r = R * (1 - s.el / 90);
const ang = (s.az - 90) * Math.PI / 180;
const x = cx + r * Math.cos(ang);
const y = cy + r * Math.sin(ang);
const rad = s.used ? 8 : 5;
const col = s.used ? t.satText : t.satTextDim;
_addLabel(ov, s.prn, x + rad + 2, y + 4, col, { size: 8 });
});
/* Leyenda de sistemas */
let lx = 6, ly = H - 4;
Object.entries(SYS_COLOR).forEach(([sys, col]) => {
const c = t.override || col;
/* Cuadrito de color en canvas (sin texto) */
ctx.fillStyle = c;
ctx.fillRect(lx, ly - 7, 7, 7);
/* Letra del sistema en overlay */
_addLabel(ov, sys[0], lx + 9, ly, t.label, { size: 8 });
lx += 22;
});
}
/* ── SNR bars ─────────────────────────────────────────────────────────── */
function drawSNR(sats) {
const canvas = document.getElementById('snr-canvas');
if (!canvas) return;
const t = _theme();
const { ctx, W, H } = _setupCanvas(canvas);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = t.snrBg; ctx.fillRect(0, 0, W, H);
const visible = (sats || []).filter(s => s.snr != null).slice(0, 24);
if (!visible.length) { _clearLabels('snr-labels'); return; }
const barW = Math.floor((W - 4) / visible.length) - 1;
const maxH = H - 18;
const ov = _clearLabels('snr-labels');
visible.forEach((s, i) => {
const snr = s.snr || 0;
const bh = Math.round((snr / 50) * maxH);
const x = 2 + i * (barW + 1);
const y = H - 16 - bh;
const baseCol = t.override || SYS_COLOR[s.system] || '#94a3b8';
if (bh > 0) {
const grad = ctx.createLinearGradient(x, y, x, y + bh);
grad.addColorStop(0, baseCol);
grad.addColorStop(1, baseCol + (s.used ? '80' : '28'));
ctx.fillStyle = s.used ? grad : baseCol + '38';
ctx.fillRect(x, y, barW, bh);
if (s.used) {
ctx.fillStyle = baseCol;
ctx.fillRect(x, y, barW, 2);
}
}
/* Labels HTML */
if (snr > 0) {
const snrCol = s.used ? (t.snrUsedText || baseCol) : t.snrDimText;
_addLabel(ov, snr, x + barW / 2, y - 1, snrCol, { size: 7, centerX: true });
}
/* PRN debajo */
const prnCol = s.used ? t.snrUsedText : t.snrDimText;
_addLabel(ov, s.prn, x + barW / 2, H - 1, prnCol, { size: 7, centerX: true });
});
}
/* ── API pública ──────────────────────────────────────────────────────── */
let _lastSats = [];
function update(sats) {
_lastSats = sats || [];
drawSky(_lastSats);
drawSNR(_lastSats);
const used = _lastSats.filter(s => s.used).length;
const view = _lastSats.length;
const u = document.getElementById('sat-used');
const v = document.getElementById('sat-view');
if (u) u.textContent = used;
if (v) v.textContent = view;
}
function redraw() { drawSky(_lastSats); drawSNR(_lastSats); }
return { update, redraw };
})();