'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( '' + '' + '' + '' + '' + '' + svgInner + '' ); } // 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 }); }