'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
? ('' + d.emoji + '')
: ('' +
'' +
'');
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
''
);
}
// 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(
''
);
}
// ── 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 });
}