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
+36
View File
@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.venv/
venv/
env/
.env
.env.*
*.egg-info/
dist/
build/
output/
*.db
.mypy_cache/
.pytest_cache/
.ruff_cache/
# Database files (GPS track data — user-specific, not version-controlled)
data/*.db
data/*.db-shm
data/*.db-wal
# ENC chart cache files (large binary/GeoJSON, installed at runtime)
charts/*/
!charts/.gitkeep
# Qt / PyQt artefacts
*.pyc
# General
.DS_Store
Thumbs.db
*.bak
*.log
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
"""
REST API for S-57 ENC chart management — GPS Navigator.
"""
import asyncio
import logging
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
class ScanPathRequest(BaseModel):
path: str
from backend.chart_manager import (
install_from_zip, install_from_enc, list_cells,
delete_cell, get_all_features, get_all_depths,
get_all_land, get_all_hazards, get_all_zones,
CHARTS_DIR, set_meta,
install_from_csv_zip, scan_and_install,
)
router = APIRouter(prefix="/charts", tags=["charts"])
log = logging.getLogger(__name__)
@router.get("/cells")
def get_cells():
return list_cells()
@router.post("/upload")
async def upload_chart(file: UploadFile = File(...)):
"""
Upload a chart file.
Accepts:
• .000 — single S-57 ENC cell
• .zip — NOAA ENC zip (contains .000) OR CSV-based custom zip
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in (".zip", ".000"):
raise HTTPException(400, "Only .zip or .000 files accepted")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
if suffix == ".zip":
# Auto-detect CSV vs ENC zip
import zipfile as _zf
with _zf.ZipFile(tmp_path) as z:
names = z.namelist()
has_csv = any(n.lower().endswith(".csv") for n in names)
has_enc = any(n.upper().endswith(".000") for n in names)
if has_csv and not has_enc:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_zip, tmp_path)
else:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_zip, tmp_path)
else:
orig_name = Path(file.filename).stem.upper() if file.filename else None
cell_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_enc, tmp_path, orig_name)
installed = [cell_id]
except Exception as e:
log.exception("Chart upload failed: %s", e)
raise HTTPException(500, "Chart processing failed — check server logs for details")
finally:
tmp_path.unlink(missing_ok=True)
return {"installed": installed}
@router.delete("/cells/{cell_id}")
def remove_cell(cell_id: str):
delete_cell(cell_id)
return {"deleted": cell_id.upper()}
@router.patch("/cells/{cell_id}/region")
def set_cell_region(cell_id: str, region: str):
region = (region or "").upper().strip()
if region not in ("A", "B"):
raise HTTPException(400, "region must be 'A' or 'B'")
try:
set_meta(cell_id, region=region)
except FileNotFoundError:
raise HTTPException(404, f"Cell {cell_id} not installed")
return {"id": cell_id.upper(), "region": region}
@router.get("/features")
def chart_features():
return JSONResponse(get_all_features())
@router.get("/depths")
def chart_depths(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_depths(bbox))
@router.get("/land")
def chart_land():
return JSONResponse(get_all_land())
@router.get("/hazards")
def chart_hazards():
return JSONResponse(get_all_hazards())
@router.get("/zones")
def chart_zones():
return JSONResponse(get_all_zones())
@router.post("/scan-path")
async def scan_path(body: ScanPathRequest):
"""
Scan a local directory (e.g. SD card drive letter) for .000 / .zip chart
files and install them.
Body: { "path": "E:\\ENC_Charts" }
"""
directory = (body.path or "").strip()
if not directory:
raise HTTPException(400, "path is required")
try:
result = await asyncio.get_event_loop().run_in_executor(
None, scan_and_install, directory)
except (FileNotFoundError, NotADirectoryError) as exc:
raise HTTPException(404, str(exc))
except Exception as exc:
log.exception("scan-path failed: %s", exc)
raise HTTPException(500, "Scan failed — check server logs for details")
return result
+160
View File
@@ -0,0 +1,160 @@
"""SQLite persistence for waypoints and routes."""
import sqlite3, json, uuid
from pathlib import Path
from datetime import datetime, timezone
def _conn(db_path: Path):
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
return con
def init_db(db_path: Path):
db_path.parent.mkdir(parents=True, exist_ok=True)
con = _conn(db_path)
con.executescript("""
CREATE TABLE IF NOT EXISTS waypoints (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
notes TEXT,
mark_type TEXT DEFAULT '',
locked INTEGER DEFAULT 0,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS routes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
wpt_ids TEXT NOT NULL, -- JSON array of waypoint ids in order
created_at TEXT
);
CREATE TABLE IF NOT EXISTS track_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL,
lon REAL,
sog REAL,
cog REAL,
alt REAL,
hdop REAL,
ts TEXT
);
""")
con.commit()
# Migration: add mark_type column to existing DBs that don't have it
try:
con.execute("ALTER TABLE waypoints ADD COLUMN mark_type TEXT DEFAULT ''")
con.commit()
except Exception:
pass # column already exists
try:
con.execute("ALTER TABLE waypoints ADD COLUMN locked INTEGER DEFAULT 0")
con.commit()
except Exception:
pass
con.close()
# ── Waypoints ─────────────────────────────────────────────────────────────────
def get_waypoints(db_path: Path) -> list:
con = _conn(db_path)
rows = con.execute("SELECT * FROM waypoints ORDER BY created_at").fetchall()
con.close()
return [dict(r) for r in rows]
def save_waypoint(db_path: Path, data: dict) -> dict:
wid = data.get("id") or str(uuid.uuid4())[:8].upper()
now = datetime.now(timezone.utc).isoformat()
con = _conn(db_path)
con.execute("""
INSERT INTO waypoints (id, name, lat, lon, notes, mark_type, locked, created_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, lat=excluded.lat, lon=excluded.lon,
notes=excluded.notes, mark_type=excluded.mark_type,
locked=excluded.locked
""", (wid, data["name"], data["lat"], data["lon"],
data.get("notes", ""), data.get("mark_type", ""),
int(data.get("locked", 0)),
data.get("created_at", now)))
con.commit()
con.close()
return {**data, "id": wid, "created_at": now}
def delete_waypoint(db_path: Path, wid: str):
con = _conn(db_path)
con.execute("DELETE FROM waypoints WHERE id=?", (wid,))
con.commit()
con.close()
# ── Routes ────────────────────────────────────────────────────────────────────
def get_routes(db_path: Path) -> list:
con = _conn(db_path)
rows = con.execute("SELECT * FROM routes ORDER BY created_at").fetchall()
con.close()
result = []
for r in rows:
d = dict(r)
d["wpt_ids"] = json.loads(d["wpt_ids"])
result.append(d)
return result
def save_route(db_path: Path, data: dict) -> dict:
rid = data.get("id") or str(uuid.uuid4())[:8].upper()
now = datetime.now(timezone.utc).isoformat()
con = _conn(db_path)
con.execute("""
INSERT INTO routes (id, name, wpt_ids, created_at)
VALUES (?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, wpt_ids=excluded.wpt_ids
""", (rid, data["name"], json.dumps(data.get("wpt_ids", [])),
data.get("created_at", now)))
con.commit()
con.close()
return {**data, "id": rid, "wpt_ids": data.get("wpt_ids", []), "created_at": now}
def delete_route(db_path: Path, rid: str):
con = _conn(db_path)
con.execute("DELETE FROM routes WHERE id=?", (rid,))
con.commit()
con.close()
# ── Track log ─────────────────────────────────────────────────────────────────
def log_position(db_path: Path, fix: dict):
con = _conn(db_path)
con.execute(
"INSERT INTO track_log (lat,lon,sog,cog,alt,hdop,ts) VALUES (?,?,?,?,?,?,?)",
(fix.get("lat"), fix.get("lon"), fix.get("sog"), fix.get("cog"),
fix.get("altitude"), fix.get("hdop"),
datetime.now(timezone.utc).isoformat())
)
con.commit()
con.close()
def get_track(db_path: Path, limit: int = 2000) -> list:
con = _conn(db_path)
rows = con.execute(
"SELECT lat,lon,sog,cog,alt,hdop,ts FROM track_log ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
con.close()
return [dict(r) for r in reversed(rows)]
def clear_track(db_path: Path):
con = _conn(db_path)
con.execute("DELETE FROM track_log")
con.commit()
con.close()
+210
View File
@@ -0,0 +1,210 @@
"""NMEA 0183 serial reader — parses GGA, RMC, VTG, GSV, GSA, GLL.
Runs in a background thread; calls broadcast_fn(msg) directly.
Thread safety is handled by the caller (Qt signal emit or similar)."""
import threading, serial, serial.tools.list_ports
# Known USB-serial VIDs: u-blox, CH340, FTDI, Prolific
KNOWN_VIDS = {0x1546, 0x1A86, 0x0403, 0x067B}
SYSTEM_MAP = {"GP": "GPS", "GL": "GLONASS", "GA": "Galileo",
"GB": "BeiDou", "GN": "GNSS", "GQ": "QZSS"}
class NMEAReader(threading.Thread):
def __init__(self, port: str, baud: int, broadcast_fn):
super().__init__(daemon=True, name="nmea-reader")
self.port = port
self.baud = baud
self._bcast = broadcast_fn
self._stop = threading.Event()
self._fix = {}
self._sats = {} # key="{sys}_{prn}" → dict
self._active = set() # PRN strings from GSA
# ── Auto-detect ───────────────────────────────────────────────────────────
@staticmethod
def autodetect() -> str | None:
"""Try to find a GPS serial port.
1. Match by USB vendor ID (u-blox, CH340, FTDI, Prolific) — fast and reliable.
2. If no VID match, return the first available COM port from the OS list
(works for USB-CDC devices that don't expose a VID, e.g. some clone adapters).
"""
ports = serial.tools.list_ports.comports()
# Priority: well-known GPS USB chip vendor IDs
for p in ports:
if (p.vid or 0) in KNOWN_VIDS:
return p.device
# Fallback: first port in the system list (avoid brute-force which can hang on BT ports)
if ports:
return ports[0].device
return None
@staticmethod
def list_ports():
return [{"port": p.device, "desc": p.description,
"vid": p.vid, "pid": p.pid}
for p in serial.tools.list_ports.comports()]
# ── Thread control ────────────────────────────────────────────────────────
def stop(self):
self._stop.set()
def _emit(self, msg: dict):
self._bcast(msg)
# ── Main loop ─────────────────────────────────────────────────────────────
def run(self):
try:
ser = serial.Serial(self.port, self.baud, timeout=0.3)
except Exception as e:
self._emit({"type": "error", "msg": str(e)})
return
self._emit({"type": "connected", "port": self.port, "baud": self.baud})
while not self._stop.is_set():
try:
raw = ser.readline()
if not raw:
continue
line = raw.decode("ascii", errors="replace").strip()
if not line.startswith("$"):
continue
self._emit({"type": "raw", "sentence": line})
self._parse(line)
except serial.SerialException as e:
self._emit({"type": "error", "msg": str(e)})
break
except Exception:
pass
ser.close()
self._emit({"type": "disconnected"})
# ── NMEA dispatch ─────────────────────────────────────────────────────────
def _parse(self, line: str):
body = line[1:line.index("*")] if "*" in line else line[1:]
parts = body.split(",")
if not parts:
return
talker = parts[0][:2]
sentence = parts[0][2:]
dispatch = {
"GGA": self._gga, "RMC": self._rmc, "VTG": self._vtg,
"GSV": self._gsv, "GSA": self._gsa, "GLL": self._gll,
}
fn = dispatch.get(sentence)
if fn:
try:
fn(parts, talker)
except Exception:
pass
# ── Helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _lat(v, h):
if not v: return None
d = int(v[:2]); m = float(v[2:])
return -(d + m/60) if h == "S" else (d + m/60)
@staticmethod
def _lon(v, h):
if not v: return None
d = int(v[:3]); m = float(v[3:])
return -(d + m/60) if h == "W" else (d + m/60)
@staticmethod
def _f(v):
s = v.split("*")[0].strip() if v else ""
return float(s) if s else None
@staticmethod
def _i(v):
s = v.split("*")[0].strip() if v else ""
return int(s) if s else None
def _sat_list(self):
return list(self._sats.values())
# ── Sentence handlers ─────────────────────────────────────────────────────
def _gga(self, p, talker):
if len(p) < 10: return
lat = self._lat(p[2], p[3])
lon = self._lon(p[4], p[5])
fq = self._i(p[6]) or 0
sats = self._i(p[7]) or 0
hdop = self._f(p[8])
alt = self._f(p[9])
self._fix.update({"lat": lat, "lon": lon, "fix_quality": fq,
"satellites": sats, "hdop": hdop, "altitude": alt,
"utc": p[1]})
self._emit({"type": "position", **self._fix, "sats": self._sat_list()})
def _rmc(self, p, talker):
if len(p) < 9: return
if p[2] != "A": return # void
lat = self._lat(p[3], p[4])
lon = self._lon(p[5], p[6])
sog = self._f(p[7])
cog = self._f(p[8])
magvar = None
if len(p) > 11 and p[10]:
mv = self._f(p[10])
if mv is not None:
magvar = -mv if (len(p) > 11 and p[11].startswith("W")) else mv
self._fix.update({"sog": sog, "cog": cog, "magvar": magvar,
"date": p[9]})
self._emit({"type": "rmc", "lat": lat, "lon": lon,
"sog": sog, "cog": cog, "magvar": magvar, "date": p[9]})
def _vtg(self, p, talker):
if len(p) < 8: return
mode = p[9].split("*")[0] if len(p) > 9 else ""
if mode == "N": return
cog_t = self._f(p[1])
cog_m = self._f(p[3])
sog = self._f(p[5])
self._fix.update({"cog": cog_t, "cog_m": cog_m, "sog": sog})
def _gsv(self, p, talker):
sys = SYSTEM_MAP.get(talker, talker)
i = 4
while i + 2 < len(p):
prn = p[i].strip()
el = self._i(p[i+1]) if i+1 < len(p) else None
az = self._i(p[i+2]) if i+2 < len(p) else None
snr_raw = p[i+3] if i+3 < len(p) else ""
snr = self._i(snr_raw)
if prn:
key = f"{sys}_{prn}"
self._sats[key] = {
"key": key, "prn": prn, "system": sys,
"el": el, "az": az, "snr": snr,
"used": prn in self._active,
}
i += 4
self._emit({"type": "satellites", "sats": self._sat_list()})
def _gsa(self, p, talker):
if len(p) < 15: return
self._active = {p[i].strip() for i in range(3, 15) if p[i].strip()}
fix_mode = self._i(p[2]) or 1
pdop = self._f(p[15]) if len(p) > 15 else None
hdop = self._f(p[16]) if len(p) > 16 else None
vdop = self._f(p[17]) if len(p) > 17 else None
self._fix.update({"fix_mode": fix_mode, "pdop": pdop,
"hdop": hdop, "vdop": vdop})
self._emit({"type": "dop", "fix_mode": fix_mode,
"pdop": pdop, "hdop": hdop, "vdop": vdop})
# refresh used flag
for k, s in self._sats.items():
s["used"] = s["prn"] in self._active
def _gll(self, p, talker):
if len(p) < 6: return
status = p[6] if len(p) > 6 else p[5]
if "A" not in status: return
lat = self._lat(p[1], p[2])
lon = self._lon(p[3], p[4])
if lat and lon:
self._fix.update({"lat": lat, "lon": lon})
+303
View File
@@ -0,0 +1,303 @@
"""Python ↔ JavaScript bridge for GPS Navigator (PyQt5 standalone).
Exposed to JS as window.py via QWebChannel.
"""
import json
import os
import threading
from pathlib import Path
from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal
class GPSBridge(QObject):
# Signal: emitted from NMEA reader thread → received in JS via .connect()
# Qt automatically queues cross-thread signal delivery — no asyncio needed.
gpsMessage = pyqtSignal(str)
def __init__(self, db_path: Path, parent=None):
super().__init__(parent)
self._db_path = db_path
self._reader = None
self._last_fix: dict = {}
self._track_interval = int(os.getenv("TRACK_INTERVAL_SEC", 5))
self._track_counter = 0
# ── Called by NMEAReader background thread ────────────────────────────────
def _on_nmea(self, msg: dict):
if msg.get("type") == "position" and msg.get("fix_quality", 0) > 0:
self._last_fix.update(msg)
self._track_counter += 1
if self._track_counter >= self._track_interval:
self._track_counter = 0
from backend.database import log_position
log_position(self._db_path, self._last_fix)
# Qt queues the signal delivery to the main thread — thread-safe.
self.gpsMessage.emit(json.dumps(msg))
# ── Lifecycle ─────────────────────────────────────────────────────────────
@pyqtSlot()
def autodetect_and_start(self):
"""Called by JS once QWebChannel is ready — ensures signal handler
is connected before any GPS messages are emitted."""
from backend.nmea_reader import NMEAReader
if self._reader and self._reader.is_alive():
return # already running
port = os.getenv("GPS_PORT", "") or NMEAReader.autodetect()
baud = int(os.getenv("GPS_BAUD", 9600))
if port:
self._reader = NMEAReader(port, baud, self._on_nmea)
self._reader.start()
else:
# Emit a status so JS knows autodetect found nothing
self.gpsMessage.emit(
json.dumps({"type": "no_port", "msg": "No GPS port detected"})
)
def shutdown(self):
if self._reader:
self._reader.stop()
self._reader = None
# ── GPS port management ───────────────────────────────────────────────────
@pyqtSlot(result=str)
def list_ports(self):
from backend.nmea_reader import NMEAReader
return json.dumps(NMEAReader.list_ports())
@pyqtSlot(str, int)
def connect_gps(self, port: str, baud: int):
from backend.nmea_reader import NMEAReader
if self._reader and self._reader.is_alive():
old = self._reader
old.stop()
# Join in background thread so we don't block the event loop
def _restart():
old.join(timeout=1.5)
r = NMEAReader(port, baud, self._on_nmea)
self._reader = r
r.start()
threading.Thread(target=_restart, daemon=True).start()
else:
self._reader = NMEAReader(port, baud, self._on_nmea)
self._reader.start()
@pyqtSlot()
def disconnect_gps(self):
if self._reader:
self._reader.stop()
self._reader = None
@pyqtSlot(result=str)
def get_status(self):
return json.dumps({
"connected": self._reader is not None and self._reader.is_alive(),
"port": self._reader.port if self._reader else None,
"fix": self._last_fix,
})
# ── Waypoints ─────────────────────────────────────────────────────────────
@pyqtSlot(result=str)
def get_waypoints(self):
from backend.database import get_waypoints
return json.dumps(get_waypoints(self._db_path))
@pyqtSlot(str, result=str)
def save_waypoint(self, data_json: str):
from backend.database import save_waypoint
return json.dumps(save_waypoint(self._db_path, json.loads(data_json)))
@pyqtSlot(str)
def delete_waypoint(self, wid: str):
from backend.database import delete_waypoint
delete_waypoint(self._db_path, wid)
# ── Routes ────────────────────────────────────────────────────────────────
@pyqtSlot(result=str)
def get_routes(self):
from backend.database import get_routes
return json.dumps(get_routes(self._db_path))
@pyqtSlot(str, result=str)
def save_route(self, data_json: str):
from backend.database import save_route
return json.dumps(save_route(self._db_path, json.loads(data_json)))
@pyqtSlot(str)
def delete_route(self, rid: str):
from backend.database import delete_route
delete_route(self._db_path, rid)
# ── Track log ─────────────────────────────────────────────────────────────
@pyqtSlot(int, result=str)
def get_track(self, limit: int):
from backend.database import get_track
return json.dumps(get_track(self._db_path, limit))
@pyqtSlot()
def clear_track(self):
from backend.database import clear_track
clear_track(self._db_path)
# ── Charts ────────────────────────────────────────────────────────────────
@pyqtSlot(result=str)
def get_chart_cells(self):
from backend.chart_manager import list_cells
return json.dumps(list_cells())
# Allowed data types for get_cell_data — prevents path traversal via data_type
_ALLOWED_DATA_TYPES = frozenset(
{"features", "land", "depths", "hazards", "zones"}
)
@pyqtSlot(str, str, result=str)
def get_cell_data(self, cell_id: str, data_type: str):
"""Lee el GeoJSON de una celda concreta (features/land/depths/hazards/zones).
- Para depths: filtra SOUNDG (puntos individuales, no útiles en zoom general).
- Si el resultado supera ~600KB: aplica decimación stride=3 en coordenadas
para mantener cada mensaje QWebChannel dentro de límites seguros.
Igual que ECDIS: un slot por celda/tipo — nunca agrega todas las celdas.
IMPORTANTE: siempre retorna JSON válido aunque falle — si el slot lanza una
excepción, PyQt5 puede no llamar al callback JS y la Promise queda colgada."""
import logging as _log
try:
from backend.chart_manager import CHARTS_DIR
# Guard against path traversal: cell_id and data_type must not
# contain path separators or parent-directory references.
if (not cell_id
or any(c in cell_id for c in ("/", "\\", "..", ":"))
or data_type not in self._ALLOWED_DATA_TYPES):
return json.dumps({'type': 'FeatureCollection', 'features': []})
path = CHARTS_DIR / cell_id / f'{data_type}.geojson'
# Ensure the resolved path stays inside CHARTS_DIR (defense in depth)
try:
path.resolve().relative_to(CHARTS_DIR.resolve())
except ValueError:
return json.dumps({'type': 'FeatureCollection', 'features': []})
if not path.exists():
return json.dumps({'type': 'FeatureCollection', 'features': []})
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
feats = data.get('features', [])
# Filtrar SOUNDG (puntos de sondeo — demasiados para zoom general)
if data_type == 'depths':
feats = [ft for ft in feats
if ft.get('properties', {}).get('layer') != 'SOUNDG']
# Decimación progresiva para mantener cada mensaje QWebChannel < ~700 KB.
raw_json = json.dumps({'type': 'FeatureCollection', 'features': feats})
if len(raw_json) > 600_000:
feats = [self._decimate_feature(ft, stride=3) for ft in feats]
raw_json = json.dumps({'type': 'FeatureCollection', 'features': feats})
if len(raw_json) > 700_000:
feats = [self._decimate_feature(ft, stride=5) for ft in feats]
raw_json = json.dumps({'type': 'FeatureCollection', 'features': feats})
return raw_json
except Exception as e:
_log.getLogger(__name__).error(
"get_cell_data %s/%s: %s", cell_id, data_type, e)
return json.dumps({'type': 'FeatureCollection', 'features': []})
@staticmethod
def _decimate_feature(feat: dict, stride: int) -> dict:
"""Reduce densidad de vértices en polígonos/líneas para achicar el JSON."""
import copy
geom = copy.deepcopy(feat.get('geometry', {}))
gtype = geom.get('type', '')
def dec_ring(ring):
if len(ring) <= 6:
return ring
pts = ring[::stride]
if pts[-1] != ring[-1]:
pts.append(ring[-1])
return pts
if gtype == 'Polygon':
geom['coordinates'] = [dec_ring(r) for r in geom.get('coordinates', [])]
elif gtype == 'MultiPolygon':
geom['coordinates'] = [[dec_ring(r) for r in poly]
for poly in geom.get('coordinates', [])]
elif gtype == 'LineString':
cs = geom.get('coordinates', [])
geom['coordinates'] = cs[::stride] if len(cs) > 6 else cs
return {'type': feat.get('type', 'Feature'),
'geometry': geom,
'properties': feat.get('properties', {})}
@pyqtSlot(result=str)
def get_chart_zones(self):
from backend.chart_manager import get_all_zones
return json.dumps(get_all_zones())
@pyqtSlot(result=str)
def get_chart_land(self):
from backend.chart_manager import get_all_land
return json.dumps(get_all_land())
@pyqtSlot(str)
def delete_chart(self, cell_id: str):
from backend.chart_manager import delete_cell
delete_cell(cell_id)
@pyqtSlot(str, str)
def set_chart_region(self, cell_id: str, region: str):
from backend.chart_manager import set_meta
set_meta(cell_id, region=region.upper())
@pyqtSlot(str, result=str)
def scan_charts_path(self, path: str):
from backend.chart_manager import scan_and_install
try:
result = scan_and_install(path)
except (FileNotFoundError, NotADirectoryError) as e:
result = {"installed": [], "skipped": [],
"errors": [{"file": path, "error": str(e)}]}
except Exception as e:
result = {"installed": [], "skipped": [],
"errors": [{"file": path, "error": str(e)}]}
return json.dumps(result)
@pyqtSlot(result=str)
def open_chart_file_dialog(self):
"""Open Qt native file dialog — install selected .000/.zip chart files."""
import zipfile as _zf
from PyQt5.QtWidgets import QFileDialog
from backend.chart_manager import (
install_from_enc, install_from_zip, install_from_csv_zip
)
files, _ = QFileDialog.getOpenFileNames(
None, "Open ENC Chart", "",
"ENC Charts (*.000 *.zip);;All Files (*)"
)
if not files:
return json.dumps({"installed": [], "skipped": [], "errors": []})
installed, skipped, errors = [], [], []
for fpath in files:
fp = Path(fpath)
try:
if fp.suffix.lower() == ".zip":
with _zf.ZipFile(fp) as z:
names = z.namelist()
has_csv = any(n.lower().endswith(".csv") for n in names)
has_enc = any(n.upper().endswith(".000") for n in names)
ids = install_from_csv_zip(fp) if (has_csv and not has_enc) \
else install_from_zip(fp)
installed.extend(ids)
elif fp.suffix.upper() == ".000":
cid = install_from_enc(fp, fp.stem.upper())
installed.append(cid)
except Exception as exc:
errors.append({"file": fp.name, "error": str(exc)})
return json.dumps({"installed": installed, "skipped": skipped, "errors": errors})
+46
View File
@@ -0,0 +1,46 @@
{
"_comment": "AR Electronics — Paleta oficial de marca. Usar en todas las apps.",
"_version": "1.0.0",
"background": {
"primary": "#0D1B2A",
"secondary": "#1A2744",
"card": "#162035",
"surface": "#1E2D47"
},
"accent": {
"blue_electric": "#2563EB",
"blue_neon": "#4A9FE8",
"blue_dark": "#1A47A8",
"blue_glow": "#60B8FF"
},
"text": {
"primary": "#E2E8F0",
"secondary": "#A8B5C4",
"muted": "#6B7A8D",
"on_accent": "#FFFFFF"
},
"status": {
"ok": "#22C55E",
"warning": "#F59E0B",
"alarm": "#EF4444",
"info": "#4A9FE8"
},
"metallic": {
"silver_light": "#C8D2DC",
"silver_mid": "#A8B5C4",
"silver_dark": "#6B7A8D"
},
"flutter": {
"_comment": "Valores listos para copiar en ThemeData de Flutter",
"primaryColor": "0xFF2563EB",
"scaffoldBackground": "0xFF0D1B2A",
"cardColor": "0xFF162035",
"accentColor": "0xFF4A9FE8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+856
View File
@@ -0,0 +1,856 @@
/* GPS Navigator — Touch-ready maritime UI */
/* ── AR Electronics brand tokens ───────────────────────────────────────────
ADDITIVOS — no reemplazan la paleta operacional existente.
Usar --brand-* solo para elementos de chrome UI (logo, topbar, pantallas
informativas). */
:root {
--brand-navy: #0D1B2A;
--brand-navy-mid: #1A2744;
--brand-blue-electric: #2563EB;
--brand-blue-neon: #4A9FE8;
--brand-blue-glow: #60B8FF;
--brand-text: #E2E8F0;
--brand-silver: #C8D2DC;
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
/* ══════════════════════════════════════════════════════════════════════════════
PALETAS DE COLOR
Todas las variables se sobreescriben por modo. Ningún elemento usa colores
hardcodeados (excepto los overlays del mapa, que siempre van oscuros).
color-mix() NO se usa — no existe en Chromium 87 (PyQt5 WebEngine).
══════════════════════════════════════════════════════════════════════════════ */
/* ── DAY — puente cubierto / nublado / pantalla interior ─────────────────── */
:root {
--bg: #0a1628;
--bg2: #0f2040;
--bg3: #152848;
--bg4: #1a3258;
--border: #1e3e6a;
--text: #cce4ff;
--muted: #4878a8;
--dim: #243a58;
--cyan: #00d8f0;
--cyan2: #0096b4;
--green: #2edc78;
--yellow: #f8cc38;
--red: #f46060;
--ok: #2edc78;
--warn: #f8cc38;
--err: #f46060;
/* glow para text-shadow: versión semitransparente del color primario */
--cyan-glow: rgba(0,216,240,0.32);
--ok-glow: rgba(46,220,120,0.40);
/* overlay del mapa — siempre oscuro para máximo contraste sobre tiles */
--overlay-bg: rgba(10,22,40,0.92);
--overlay-text: #a8c8e8;
/* modo-botón activo */
--modebtn-active-text: var(--text);
/* badge borders */
--badge-err-border: rgba(244,96,96,0.40);
--badge-ok-border: rgba(46,220,120,0.40);
--badge-fix-border: rgba(0,216,240,0.40);
/* layout */
--header-h: 56px;
--lp-w: 270px;
--rp-w: 280px;
--ol-filter: none;
--radius: 6px;
--touch: 48px;
}
/* ── DUSK — atardecer, conservar visión nocturna ─────────────────────────── */
html[data-mode="dusk"] {
--bg: #050910; --bg2: #080e1c; --bg3: #0c1424; --bg4: #101a2e;
--border: #162030; --text: #6080a0; --muted: #364e68; --dim: #1a2a3c;
--cyan: #2070a0; --cyan2: #185880;
--green: #186840; --yellow:#806010; --red: #883030;
--ok: #186840; --warn: #806010; --err: #883030;
--cyan-glow: rgba(32,112,160,0.28);
--ok-glow: rgba(24,104,64,0.30);
--overlay-bg: rgba(5,9,16,0.94);
--overlay-text: #506888;
--modebtn-active-text: #9ab8d0;
--badge-err-border: rgba(136,48,48,0.40);
--badge-ok-border: rgba(24,104,64,0.40);
--badge-fix-border: rgba(32,112,160,0.40);
--ol-filter: brightness(0.58) saturate(0.62);
}
/* ── NOCHE — visión nocturna, solo rojo tenue ────────────────────────────── */
html[data-mode="night"] {
--bg: #0a0000; --bg2: #120000; --bg3: #1a0202; --bg4: #220404;
--border: #320808; --text: #983020; --muted: #582010; --dim: #240808;
--cyan: #b83020; --cyan2: #882018;
--green: #802010; --yellow:#903010; --red: #c03030;
--ok: #802010; --warn: #903010; --err: #c03030;
--cyan-glow: rgba(184,48,32,0.28);
--ok-glow: rgba(128,32,16,0.28);
--overlay-bg: rgba(10,0,0,0.96);
--overlay-text: #602010;
--modebtn-active-text: #d08070;
--badge-err-border: rgba(192,48,48,0.40);
--badge-ok-border: rgba(128,32,16,0.40);
--badge-fix-border: rgba(184,48,32,0.40);
--ol-filter: brightness(0.26) saturate(0.12) sepia(0.95) hue-rotate(-22deg);
}
/* ── DAY+ — plena luz solar, máximo contraste ────────────────────────────── */
html[data-mode="dayplus"] {
--bg: #e8f2fc; /* blanco azulado claro */
--bg2: #ffffff; /* paneles blancos puros */
--bg3: #dae8f6; /* inputs */
--bg4: #c8d8ec; /* hover */
--border: #5080b0; /* azul medio — borde visible */
--text: #041220; /* casi negro */
--muted: #2050a0; /* azul oscuro legible */
--dim: #90aac8; /* separadores */
--cyan: #0050a0; /* azul profundo — readouts sobre blanco */
--cyan2: #003880; /* azul más oscuro */
--green: #006030; /* verde oscuro */
--yellow: #906000; /* ámbar oscuro */
--red: #c02020; /* rojo oscuro */
--ok: #006030;
--warn: #906000;
--err: #c02020;
--cyan-glow: transparent; /* no glow en modo claro */
--ok-glow: transparent;
--overlay-bg: rgba(4,18,32,0.90); /* mapa toolbar — siempre oscuro */
--overlay-text: #a0c0e0;
/* mode-btn activo: texto claro sobre fondo oscuro (--cyan2 = #003880) */
--modebtn-active-text: #e0eeff;
--badge-err-border: rgba(192,32,32,0.50);
--badge-ok-border: rgba(0,96,48,0.50);
--badge-fix-border: rgba(0,80,160,0.50);
--ol-filter: brightness(1.06) saturate(1.08);
}
/* ══════════════════════════════════════════════════════════════════════════════
RESET
══════════════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; overflow: hidden;
background: var(--bg); color: var(--text);
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-size: 14px; -webkit-tap-highlight-color: transparent;
}
/* ══════════════════════════════════════════════════════════════════════════════
HEADER
══════════════════════════════════════════════════════════════════════════════ */
header {
height: var(--header-h);
background: var(--bg2);
border-bottom: 2px solid var(--border);
display: flex; align-items: center; gap: 16px; padding: 0 18px;
flex-shrink: 0; overflow: hidden;
}
/* Brand */
.brand {
display: flex; align-items: center; gap: 8px;
font-size: 1.05rem; font-weight: 800; letter-spacing: 2px;
color: var(--cyan); white-space: nowrap; flex-shrink: 0;
text-shadow: 0 0 16px var(--cyan-glow);
}
.brand-logo { height: 26px; width: auto; display: block; filter: drop-shadow(0 0 4px rgba(0,216,240,0.35)); }
.brand-name { display: flex; flex-direction: column; line-height: 1.1; }
.brand-sub { font-size: 0.58em; font-weight: 400; letter-spacing: 2px; color: var(--text); }
.brand span { color: var(--text); font-weight: 400; letter-spacing: 1px; }
/* GPS status: dot + port label, sin borde de chip */
.hdr-gps {
display: flex; align-items: center; gap: 5px; flex-shrink: 0;
}
.status-dot {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
background: var(--muted);
transition: background 0.3s, box-shadow 0.3s;
}
.status-dot.dot-ok {
background: var(--ok);
box-shadow: 0 0 6px var(--ok-glow), 0 0 14px var(--ok-glow);
}
.status-dot.dot-err { background: var(--err); }
.hdr-port {
font-size: 0.68rem; font-family: 'JetBrains Mono', monospace;
color: var(--muted); font-weight: 600; white-space: nowrap;
}
/* Fix badge — elemento independiente */
.fix-badge {
padding: 3px 8px; border-radius: 20px; flex-shrink: 0;
font-size: 0.62rem; font-weight: 700;
font-family: 'JetBrains Mono', monospace; letter-spacing: 0.5px;
white-space: nowrap;
}
.fix-none { background: rgba(220,80,80,0.16); color: var(--err); border: 1px solid var(--badge-err-border); }
.fix-ok { background: rgba(46,180,100,0.16); color: var(--ok); border: 1px solid var(--badge-ok-border); }
.fix-dgps { background: rgba(0,210,140,0.18); color: #00d28c; border: 1px solid rgba(0,210,140,0.45); } /* DGPS — verde esmeralda */
.fix-great { background: rgba(0,180,220,0.16); color: var(--cyan); border: 1px solid var(--badge-fix-border); }
/* Separador vertical para header */
.hdr-sep {
width: 1px; height: 22px; background: var(--border); flex-shrink: 0;
}
/* Botón PORT — círculo pequeño con ícono */
.port-btn {
width: 30px; height: 30px; flex-shrink: 0;
background: var(--bg3); border: 1px solid var(--border);
color: var(--muted); border-radius: 50%;
cursor: pointer; font-size: 0.8rem;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.port-btn:hover { border-color: var(--cyan); color: var(--cyan); }
.port-btn:active { background: var(--bg4); }
/* Mode selector — segmented control */
.mode-seg {
display: flex; flex-shrink: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden; background: var(--bg3);
}
.mode-btn {
height: 30px; padding: 0 10px;
background: transparent; border: none;
border-left: 1px solid var(--border);
color: var(--muted);
cursor: pointer; font-size: 0.62rem; font-weight: 700;
letter-spacing: 0.5px; white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.mode-btn:first-child { border-left: none; }
.mode-btn:hover { background: var(--bg4); color: var(--text); }
.mode-btn.active { background: var(--cyan2); color: var(--modebtn-active-text); }
/* UTC clock */
.utc-clock {
flex-shrink: 0;
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem; color: var(--muted); letter-spacing: 0.5px;
}
/* ══════════════════════════════════════════════════════════════════════════════
LAYOUT PRINCIPAL
══════════════════════════════════════════════════════════════════════════════ */
#app { display: flex; flex-direction: column; height: 100%; }
#main { flex: 1; display: flex; overflow: hidden; min-height: 0; }
/* ── Panel izquierdo (GPS readout + tabs) ────────────────────────────────── */
#left-panel {
width: var(--lp-w); min-width: var(--lp-w);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden; /* el scroll va en .lp-content */
}
/* Tabs del panel izquierdo */
.lp-tabs {
display: flex; gap: 5px; padding: 8px 8px 0; flex-shrink: 0;
background: var(--bg2); border-bottom: 2px solid var(--border);
}
.lp-tab {
flex: 1; height: 44px;
background: var(--bg3); border: 1px solid var(--border);
color: var(--muted); border-radius: var(--radius) var(--radius) 0 0;
cursor: pointer; font-size: 0.62rem; font-weight: 700;
letter-spacing: 0.4px; transition: all 0.15s;
display: flex; align-items: center; justify-content: center; gap: 3px;
border-bottom: 2px solid transparent; position: relative; bottom: -2px;
}
.lp-tab:hover { color: var(--text); border-color: var(--cyan); border-bottom-color: transparent; }
.lp-tab:active { color: var(--text); border-color: var(--cyan); border-bottom-color: transparent; }
.lp-tab.active {
background: var(--bg2); border-color: var(--border);
border-bottom-color: var(--bg2); /* fusiona con el contenido */
color: var(--cyan); font-weight: 800;
}
/* Contenedor de cada tab */
.lp-content {
flex: 1; overflow-y: auto; padding: 10px;
display: flex; flex-direction: column; gap: 2px;
}
.lp-content.hidden { display: none; }
/* Toolbar dentro de los tabs WPT / RTE */
.lp-section-tools {
display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap;
}
.lp-section {
border-bottom: 1px solid var(--border);
padding-bottom: 10px; margin-bottom: 10px;
}
.lp-section:last-child { border-bottom: none; }
.lp-title {
font-size: 0.6rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 1.5px;
margin-bottom: 7px; font-weight: 700;
}
/* Lectura grande (LAT / LON) */
.readout-big {
font-family: 'JetBrains Mono', monospace;
font-size: 1.15rem; color: var(--cyan);
font-weight: 600; line-height: 1.7;
text-shadow: 0 0 10px var(--cyan-glow);
}
.lp-row { display: flex; gap: 6px; }
.lp-field { flex: 1; min-width: 0; }
.lp-lbl {
font-size: 0.6rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 2px;
}
.lp-val {
font-family: 'JetBrains Mono', monospace;
font-size: 0.95rem; color: var(--text); font-weight: 600;
}
/* ── Mapa central ────────────────────────────────────────────────────────── */
#map-wrap { flex: 1; position: relative; min-width: 0; }
/* IMPORTANTE: NO aplicar filter directamente a #map — en Qt5 WebEngine el filter crea
un compositing layer que desplaza las coordenadas de los eventos de puntero (bug Chromium 83).
El modo nocturno se maneja por: opacidad del layer OSM (via GPSMap.setOsmOpacity) +
paleta S-52 de colores (via ChartLayer.setChartMode) + color de fondo del canvas. */
#map { width: 100%; height: 100%; background: #a8c8e8; /* ocean blue — visible sin tiles OSM */ }
/* Barra de coordenadas (always dark overlay sobre el mapa) */
#map-coords {
position: absolute; bottom: 6px; left: 50%; transform: translateX(-50%);
z-index: 10;
background: var(--overlay-bg);
color: var(--overlay-text);
font-family: 'JetBrains Mono', monospace;
font-size: 0.68rem; padding: 3px 10px; border-radius: 10px;
pointer-events: none;
border: 1px solid rgba(255,255,255,0.10);
backdrop-filter: blur(4px);
}
/* ── Map Tools panel (fondo del left panel) ──────────────────────────────── */
#lp-maptools {
flex-shrink: 0;
border-top: 2px solid var(--border);
background: var(--bg2);
padding: 8px;
}
.mt-label {
font-size: 0.58rem; font-weight: 700; letter-spacing: 1.5px;
color: var(--muted); text-transform: uppercase;
margin-bottom: 6px;
}
.mt-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.mt-btn {
height: 36px;
background: var(--bg3); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius);
cursor: pointer; font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.3px; transition: all 0.12s;
display: flex; align-items: center; justify-content: center;
}
.mt-btn:hover { border-color: var(--cyan); color: var(--cyan); background: var(--bg4); }
.mt-btn:active { background: var(--bg4); }
.mt-btn.active {
background: var(--cyan); color: var(--bg); border-color: var(--cyan);
}
/* Draw modes activos → amarillo */
.mt-btn.active#btn-draw-wpt,
.mt-btn.active#btn-draw-route {
background: var(--yellow); color: #0a1628; border-color: var(--yellow);
}
/* Chart name indicator sobre el mapa (bottom-right) */
#map-chart-info {
position: absolute; bottom: 32px; right: 8px; z-index: 10;
background: var(--overlay-bg);
color: var(--cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem; padding: 2px 8px; border-radius: 8px;
pointer-events: none;
border: 1px solid rgba(0,216,240,0.25);
opacity: 0; transition: opacity 0.2s;
white-space: nowrap;
}
#map-chart-info.visible { opacity: 1; }
/* Compatibilidad: .tb-btn se mantiene para no romper nada */
.tb-btn {
height: var(--touch); min-width: var(--touch);
background: var(--bg3); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius);
cursor: pointer; font-size: 0.72rem; font-weight: 700;
transition: all 0.15s;
display: flex; align-items: center; justify-content: center; padding: 0 10px;
}
.tb-btn:hover { border-color: var(--cyan); color: var(--cyan); }
.tb-btn.active { background: var(--cyan); color: var(--bg); border-color: var(--cyan); }
/* ── Panel derecho (satélites) ───────────────────────────────────────────── */
#right-panel {
width: var(--rp-w); min-width: var(--rp-w);
background: var(--bg2); border-left: 1px solid var(--border);
padding: 10px 8px; overflow-y: auto;
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.rp-title {
font-size: 0.6rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 1.5px;
align-self: flex-start; font-weight: 700;
}
/* ── Canvas wrappers para overlay de labels HTML ─────────────────────────── */
.canvas-wrap {
position: relative;
display: block;
line-height: 0; /* evita espacio extra bajo el canvas */
}
.canvas-labels {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
overflow: hidden;
}
.canvas-labels span {
position: absolute;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
line-height: 1;
white-space: nowrap;
-webkit-font-smoothing: antialiased;
}
#sky-canvas {
border-radius: 50%; border: 1px solid var(--border);
display: block;
}
.rp-sat-count {
font-size: 0.68rem; color: var(--muted);
font-family: 'JetBrains Mono', monospace;
}
#sat-used { color: var(--ok); font-weight: 700; }
#sat-view { color: var(--cyan); }
/* ── Sección NAV activo (GO-TO waypoint) ─────────────────────────────────── */
#nav-section .lp-val { color: var(--warn); }
.small-btn {
height: 36px; padding: 0 14px;
background: var(--bg3); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius);
cursor: pointer; font-size: 0.72rem; font-weight: 600;
transition: all 0.15s; white-space: nowrap;
display: inline-flex; align-items: center;
}
.small-btn:hover, .small-btn:active {
border-color: var(--cyan); color: var(--cyan); background: var(--bg4);
}
/* ══════════════════════════════════════════════════════════════════════════════
NMEA LOG
══════════════════════════════════════════════════════════════════════════════ */
.nmea-log {
font-family: 'JetBrains Mono', monospace; font-size: 0.72rem;
line-height: 1.6; white-space: pre-wrap; color: var(--muted);
flex: 1; overflow-y: auto;
}
/* ══════════════════════════════════════════════════════════════════════════════
LISTAS (Waypoints / Routes)
══════════════════════════════════════════════════════════════════════════════ */
.hidden { display: none !important; }
.empty-list { color: var(--muted); font-size: 0.75rem; padding: 16px 4px; }
.item-list { display: flex; flex-direction: column; gap: 6px; }
.list-item {
background: var(--bg3); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 12px;
transition: border-color 0.15s;
}
.list-item:hover { border-color: var(--dim); }
.list-item.item-active { border-color: var(--warn); }
.item-name { font-weight: 700; font-size: 0.88rem; color: var(--text); }
.item-sub {
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem; color: var(--muted); margin-top: 3px;
}
.item-nav {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem; color: var(--cyan); margin-top: 3px;
}
.item-btns { display: flex; gap: 6px; margin-top: 8px; }
.icon-btn {
height: 34px; min-width: 34px; padding: 0 8px;
background: var(--bg); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius);
cursor: pointer; font-size: 0.78rem;
display: inline-flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.icon-btn:hover, .icon-btn:active { border-color: var(--cyan); color: var(--cyan); }
.icon-btn.icon-del:hover, .icon-btn.icon-del:active { border-color: var(--err); color: var(--err); }
/* ══════════════════════════════════════════════════════════════════════════════
MODALES (Waypoint / Route / PORT / ENC charts)
══════════════════════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed; top: 0; right: 0; bottom: 0; left: 0;
background: rgba(0,0,0,0.72);
display: flex; align-items: center; justify-content: center;
z-index: 300;
}
.modal {
background: var(--bg2); border: 1px solid var(--border);
border-radius: 10px; min-width: 320px; max-width: 440px; width: 95%;
box-shadow: 0 16px 48px rgba(0,0,0,0.65);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 0 16px; height: 50px;
border-bottom: 1px solid var(--border);
font-weight: 700; font-size: 0.8rem; letter-spacing: 1px;
color: var(--cyan); background: var(--bg3);
border-radius: 10px 10px 0 0;
}
.modal-close {
width: 38px; height: 38px;
background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 1.1rem;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); transition: all 0.15s;
}
.modal-close:hover, .modal-close:active { color: var(--err); background: rgba(200,60,60,0.12); }
.modal-body { padding: 16px; }
.modal-btns { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
/* Campos de formulario */
.form-field { margin-bottom: 12px; }
.form-lbl {
display: block; font-size: 0.62rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 1px;
margin-bottom: 5px; font-weight: 600;
}
.form-inp, .form-sel {
width: 100%; height: var(--touch);
background: var(--bg3); border: 1px solid var(--border);
color: var(--text); padding: 9px 10px;
border-radius: var(--radius); font-size: 0.82rem; font-family: inherit;
transition: border-color 0.15s;
}
.form-inp:focus, .form-sel:focus { outline: none; border-color: var(--cyan); }
/* Botones de acción */
.btn-primary {
height: var(--touch); padding: 0 20px;
background: var(--cyan); border: none; color: var(--bg);
border-radius: var(--radius); cursor: pointer;
font-weight: 700; font-size: 0.78rem; letter-spacing: 0.5px;
transition: opacity 0.15s;
display: inline-flex; align-items: center;
}
/* DAY+: --bg = #e8f2fc (claro) sobre --cyan = #0050a0 (oscuro) → OK */
.btn-primary:hover, .btn-primary:active { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary {
height: var(--touch); padding: 0 18px;
background: var(--bg3); border: 1px solid var(--border); color: var(--text);
border-radius: var(--radius); cursor: pointer; font-size: 0.78rem;
transition: all 0.15s;
display: inline-flex; align-items: center;
}
.btn-secondary:hover, .btn-secondary:active { border-color: var(--cyan); color: var(--cyan); }
/* Selector de waypoints para rutas */
.wpt-selector {
max-height: 160px; overflow-y: auto;
border: 1px solid var(--border); border-radius: var(--radius); padding: 4px;
}
.rte-wpt-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 6px; cursor: pointer; border-radius: 4px; min-height: 40px;
}
.rte-wpt-row:hover, .rte-wpt-row:active { background: var(--bg4); }
.rte-wpt-sub {
color: var(--muted); font-size: 0.65rem;
font-family: 'JetBrains Mono', monospace; margin-left: auto;
}
/* Modal de cartas más ancho */
#modal-charts { max-width: 500px; }
/* ── ENC layers modal (AVANZADO) ─────────────────────────────────────────── */
#modal-enc-layers { max-width: 340px; }
.el-section-hdr {
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.06em;
color: var(--cyan); text-transform: uppercase;
padding: 8px 4px 3px; margin-top: 4px;
border-top: 1px solid var(--bg4);
}
.el-section-hdr:first-child { border-top: none; margin-top: 0; }
.enc-layer-group {
display: flex; flex-direction: column; gap: 6px;
padding: 4px 0;
}
.enc-layer-lbl {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 10px; border-radius: 5px; cursor: pointer;
border: 1px solid var(--border);
background: var(--bg3);
transition: background 0.15s;
-webkit-user-select: none; user-select: none;
}
.enc-layer-lbl:hover { background: var(--bg4); }
.enc-layer-lbl input[type="checkbox"] {
margin-top: 2px; accent-color: var(--cyan); flex-shrink: 0;
width: 15px; height: 15px; cursor: pointer;
}
.enc-layer-lbl > div { display: flex; flex-direction: column; flex: 1; }
.enc-layer-lbl .el-name {
font-size: 0.76rem; font-weight: 600; color: var(--text);
line-height: 1.3; display: block;
}
.enc-layer-lbl .el-desc {
font-size: 0.63rem; color: var(--muted); margin-top: 1px; line-height: 1.3; display: block;
}
/* ── Boat Center button ──────────────────────────────────────────────────── */
.mt-boat-center {
width: 100%;
height: 40px;
background: var(--cyan2);
border-color: var(--cyan);
color: var(--text);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 1px;
}
.mt-boat-center:hover {
background: var(--cyan);
color: var(--bg);
border-color: var(--cyan);
}
/* ── Zoom controls sobre el mapa ────────────────────────────────────────── */
#map-zoom-ctrl {
position: absolute;
right: 12px;
top: 12px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 3px;
z-index: 100;
}
.map-zoom-btn {
width: 40px;
height: 40px;
background: var(--overlay-bg);
border: 1px solid rgba(255,255,255,0.18);
color: var(--overlay-text);
border-radius: var(--radius);
cursor: pointer;
font-size: 1.35rem;
font-weight: 700;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.map-zoom-btn:hover { background: rgba(0,216,240,0.28); color: #00d8f0; border-color: rgba(0,216,240,0.50); }
.map-zoom-btn:active { background: rgba(0,216,240,0.45); }
/* ── ENC detail level selector ──────────────────────────────────────────── */
.enc-level-sel {
display: flex;
gap: 3px;
margin-top: 3px;
}
.enc-lvl {
flex: 1;
padding: 5px 2px;
font-size: 0.60rem;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
background: var(--bg3);
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.enc-lvl:hover { background: var(--bg4); color: var(--text); border-color: var(--cyan2); }
.enc-lvl.active { background: var(--cyan2); color: #fff; border-color: var(--cyan); font-weight: 800; }
/* ── ENC hover tooltip ──────────────────────────────────────────────────────
Aparece flotante sobre el mapa al pasar el ratón por una ayuda a la navegación.
Diseño ECDIS: fondo oscuro, borde cyan, tipografía compacta.
──────────────────────────────────────────────────────────────────────────── */
.enc-tooltip {
position: absolute;
z-index: 200;
pointer-events: none; /* no bloquea el ratón */
background: rgba(6, 14, 28, 0.96);
border: 1px solid #00b8d0;
border-radius: 4px;
padding: 6px 10px;
min-width: 110px;
max-width: 230px;
box-shadow: 0 4px 16px rgba(0,0,0,0.80);
}
.enc-tt-type {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #00c8e8;
margin-bottom: 2px;
}
.enc-tt-name {
font-size: 0.72rem;
font-weight: 600;
color: #deeeff;
line-height: 1.3;
}
.enc-tt-light {
font-size: 0.66rem;
color: #cc88ff;
font-family: 'JetBrains Mono', monospace;
margin-top: 2px;
}
.enc-tt-cat {
font-size: 0.63rem;
color: #7898b8;
margin-top: 1px;
}
/* ── Feature info panel (panel derecho, aparece al click) ───────────────────
ECDIS-style: fila etiqueta/valor compacta, badge de color por tipo de ayuda.
──────────────────────────────────────────────────────────────────────────── */
#rp-feat-info {
margin: 8px 0 0 0;
border-top: 1px solid var(--border);
padding: 7px 0 0 0;
font-size: 0.82rem;
}
.fi-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.fi-badge {
font-size: 1.0rem;
flex-shrink: 0;
line-height: 1;
}
.fi-type {
font-size: 0.80rem;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--cyan);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fi-close {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 1.0rem;
padding: 0 2px;
flex-shrink: 0;
line-height: 1;
}
.fi-close:hover { color: var(--text); }
.fi-rows { display: table; width: 100%; border-collapse: collapse; }
.fi-row { display: table-row; }
.fi-lbl {
display: table-cell;
color: var(--muted);
padding: 2px 8px 2px 0;
white-space: nowrap;
vertical-align: top;
width: 62px;
font-size: 0.75rem;
}
.fi-val {
display: table-cell;
color: var(--text);
vertical-align: top;
font-size: 0.82rem;
word-break: break-word;
}
.fi-row.fi-light .fi-val {
color: #cc88ff;
font-family: 'JetBrains Mono', monospace;
font-size: 0.76rem;
}
.fi-empty {
font-size: 0.76rem;
color: var(--muted);
font-style: italic;
padding: 2px 0;
}
/* ── MARCA modal — selector de tipo de marca ────────────────────────────────── */
.marca-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin: 12px 0;
}
.marca-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
background: var(--bg2);
transition: border-color 0.15s, background 0.15s;
}
.marca-item:hover { border-color: var(--cyan); background: var(--bg3); }
.marca-item.selected { border-color: var(--cyan); background: var(--bg4); }
.marca-icon { font-size: 1.6rem; line-height: 1; }
.marca-label { font-size: 0.60rem; color: var(--muted); text-align: center; line-height: 1.2; }
/* ── Lock button ─────────────────────────────────────────────────────────── */
.icon-btn.icon-lock {
color: var(--muted);
font-size: 0.80rem;
}
.icon-btn.icon-lock.locked {
color: var(--yellow);
}
.list-item.item-locked {
opacity: 0.75;
border-left: 2px solid var(--yellow);
}
/* ── Mark list item ──────────────────────────────────────────────────────── */
.mark-item-icon { font-size: 1.1rem; line-height: 1; flex-shrink: 0; }
.mark-item-body { flex: 1; min-width: 0; }
+485
View File
@@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPS Navigator</title>
<!-- NOTA: NO usar document.documentElement.style.zoom — rompe las coordenadas del mapa.
CSS zoom en el elemento raíz hace que offsetWidth (que OL usa para su viewport) y
getBoundingClientRect (que OL usa para calcular evt.pixel) devuelvan valores en
espacios de coordenadas distintos → desfase sistemático en todo el mapa. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@v9.2.4/ol.css">
<link rel="stylesheet" href="css/main.css">
</head>
<body>
<div id="app">
<!-- ── HEADER ──────────────────────────────────────────────────────────── -->
<header>
<!-- Brand -->
<div class="brand">
<img src="assets/images/ar_logo_full.png" class="brand-logo" alt="AR Electronics" />
<span class="brand-name">GPS<span class="brand-sub">NAVIGATOR</span></span>
</div>
<span class="hdr-sep"></span>
<!-- GPS status: dot + port label -->
<div class="hdr-gps">
<span class="status-dot" id="dot-gps"></span>
<span id="lbl-port" class="hdr-port">NO GPS</span>
</div>
<!-- Fix badge — separado del chip GPS -->
<span id="fix-badge" class="fix-badge fix-none">NO FIX</span>
<!-- Botón PORT — ícono pequeño -->
<button class="port-btn" id="btn-connect" onclick="showConnectModal()" title="Configure GPS port"></button>
<!-- Spacer: empuja los modos hacia la derecha -->
<div style="flex:1"></div>
<!-- Selector de modo — segmented control -->
<div class="mode-seg">
<button class="mode-btn" id="mode-night" onclick="setMode('night')">NIGHT</button>
<button class="mode-btn" id="mode-dusk" onclick="setMode('dusk')">DUSK</button>
<button class="mode-btn active" id="mode-day" onclick="setMode('day')">DAY</button>
<button class="mode-btn" id="mode-dayplus" onclick="setMode('dayplus')">DAY+</button>
</div>
<span class="hdr-sep"></span>
<!-- Reloj UTC -->
<div id="utc-clock" class="utc-clock">--:--:-- UTC</div>
</header>
<!-- ── MAIN AREA ────────────────────────────────────────────────────────── -->
<div id="main">
<!-- LEFT PANEL: GPS readout + Waypoints / Routes / NMEA tabs -->
<div id="left-panel">
<!-- ── Tab selector ──────────────────────────────────────────────── -->
<div class="lp-tabs">
<button class="lp-tab active" id="lptab-gps" onclick="lpTab('gps')">GPS</button>
<button class="lp-tab" id="lptab-wpt" onclick="lpTab('wpt')">📍 WPT</button>
<button class="lp-tab" id="lptab-rte" onclick="lpTab('rte')">🗺 RTE</button>
<button class="lp-tab" id="lptab-mrk" onclick="lpTab('mrk')">📍 MRK</button>
<button class="lp-tab" id="lptab-nmea" onclick="lpTab('nmea')">📡 NMEA</button>
</div>
<!-- ── Tab: GPS ───────────────────────────────────────────────────── -->
<div id="lp-gps" class="lp-content">
<div class="lp-section">
<div class="lp-title">POSITION</div>
<div class="readout-big" id="r-lat">--°--'-.--</div>
<div class="readout-big" id="r-lon">--°--'-.--</div>
</div>
<div class="lp-section">
<div class="lp-row">
<div class="lp-field"><div class="lp-lbl">SOG</div><div class="lp-val" id="r-sog">--</div></div>
<div class="lp-field"><div class="lp-lbl">COG</div><div class="lp-val" id="r-cog">--</div></div>
</div>
<div class="lp-row" style="margin-top:6px">
<div class="lp-field"><div class="lp-lbl">COG MAG</div><div class="lp-val" id="r-cogm">--</div></div>
<div class="lp-field"><div class="lp-lbl">MAGVAR</div><div class="lp-val" id="r-magvar">--</div></div>
</div>
</div>
<div class="lp-section">
<div class="lp-row">
<div class="lp-field"><div class="lp-lbl">ALT</div><div class="lp-val" id="r-alt">--</div></div>
<div class="lp-field"><div class="lp-lbl">FIX</div><div class="lp-val" id="r-fix">--</div></div>
</div>
<div class="lp-row" style="margin-top:6px">
<div class="lp-field"><div class="lp-lbl">HDOP</div><div class="lp-val" id="r-hdop">--</div></div>
<div class="lp-field"><div class="lp-lbl">VDOP</div><div class="lp-val" id="r-vdop">--</div></div>
</div>
<div class="lp-row" style="margin-top:6px">
<div class="lp-field"><div class="lp-lbl">PDOP</div><div class="lp-val" id="r-pdop">--</div></div>
<div class="lp-field"><div class="lp-lbl">SATS</div><div class="lp-val" id="r-sats">--</div></div>
</div>
</div>
<!-- Compass + Ecosonda -->
<div class="lp-section" id="sensors-section">
<div class="lp-title">SENSORS</div>
<div class="lp-row">
<div class="lp-field"><div class="lp-lbl">HDG TRUE</div><div class="lp-val" id="r-hdg-t">--</div></div>
<div class="lp-field"><div class="lp-lbl">HDG MAG</div><div class="lp-val" id="r-hdg-m">--</div></div>
</div>
<div class="lp-row" style="margin-top:6px">
<div class="lp-field"><div class="lp-lbl">DEPTH</div><div class="lp-val" id="r-depth">--</div></div>
<div class="lp-field"><div class="lp-lbl">TEMP</div><div class="lp-val" id="r-water-temp">--</div></div>
</div>
</div>
<!-- Active waypoint navigation (GO-TO) -->
<div class="lp-section" id="nav-section" style="display:none">
<div class="lp-title" style="color:var(--cyan)">▶ GOTO</div>
<div class="lp-field" style="margin-bottom:4px">
<div class="lp-lbl">WAYPOINT</div>
<div class="lp-val" id="nav-wpt-name" style="color:var(--cyan)">--</div>
</div>
<div class="lp-row">
<div class="lp-field"><div class="lp-lbl">BRG</div><div class="lp-val" id="nav-brg">--</div></div>
<div class="lp-field"><div class="lp-lbl">DIST</div><div class="lp-val" id="nav-dist">--</div></div>
</div>
<div class="lp-row" style="margin-top:6px">
<div class="lp-field"><div class="lp-lbl">XTE</div><div class="lp-val" id="nav-xte">--</div></div>
<div class="lp-field"><div class="lp-lbl">ETA</div><div class="lp-val" id="nav-eta">--</div></div>
</div>
<button class="small-btn" onclick="stopNav()" style="margin-top:8px;width:100%">■ STOP NAV</button>
</div>
</div><!-- /lp-gps -->
<!-- ── Tab: Waypoints ─────────────────────────────────────────────── -->
<div id="lp-wpt" class="lp-content hidden">
<div class="lp-section-tools">
<button class="small-btn" onclick="addWptFromGPS()">+ FROM GPS</button>
<button class="small-btn" onclick="addWptManual()">+ MANUAL</button>
</div>
<div id="wpt-list" class="item-list"></div>
</div>
<!-- ── Tab: Routes ────────────────────────────────────────────────── -->
<div id="lp-rte" class="lp-content hidden">
<div class="lp-section-tools">
<button class="small-btn" onclick="newRoute()">+ NEW ROUTE</button>
</div>
<div id="route-list" class="item-list"></div>
</div>
<!-- ── Tab: Marcas ──────────────────────────────────────────────────── -->
<div id="lp-mrk" class="lp-content hidden">
<div id="mark-list" class="item-list"></div>
</div>
<!-- ── Tab: NMEA ──────────────────────────────────────────────────── -->
<div id="lp-nmea" class="lp-content hidden" style="padding:8px">
<div id="nmea-log" class="nmea-log"></div>
</div>
<!-- ── Map Tools — siempre visible al fondo del panel ────────────── -->
<div id="lp-maptools">
<div class="mt-label">MAP TOOLS</div>
<!-- Boat center — botón prominente, ancho completo -->
<button class="mt-btn mt-boat-center" onclick="centerOnGPS()" title="Center map on own ship">
⛵ BOAT CENTER
</button>
<div class="mt-grid" style="margin-top:4px">
<button class="mt-btn active" id="btn-north" onclick="setOrientation('N')" title="North Up">N↑</button>
<button class="mt-btn" id="btn-course" onclick="setOrientation('C')" title="Course Up">C↑</button>
<button class="mt-btn" id="btn-track" onclick="toggleTrack()" title="Toggle track">TRK</button>
<button class="mt-btn" onclick="clearTrack()" title="Clear track">✕TRK</button>
<button class="mt-btn" id="btn-draw-wpt" onclick="toggleDrawWpt()" title="Click en el mapa para añadir WPT">✚WPT</button>
<button class="mt-btn" id="btn-draw-route" onclick="toggleDrawRoute()" title="Trazar ruta en el mapa">✚RTE</button>
<button class="mt-btn" id="btn-draw-mark" onclick="openMarcaModal()" title="Colocar marca en el mapa">📍MARCA</button>
<button class="mt-btn" onclick="showChartsModal()" title="ENC charts">⛵ENC</button>
</div>
<!-- ENC detail level -->
<div class="mt-label" style="margin-top:7px">CARTAS ENC</div>
<div class="enc-level-sel">
<button class="enc-lvl active" id="enc-lvl-basic"
onclick="ChartLayer.setDetailLevel('basic')"
title="Balizas + tierra (más rápido)">BÁSICO</button>
<button class="enc-lvl" id="enc-lvl-medium"
onclick="ChartLayer.setDetailLevel('medium')"
title="+ profundidades y peligros">MEDIO</button>
<button class="enc-lvl" id="enc-lvl-advanced"
onclick="openEncLayersModal()"
title="Seleccionar capas ENC">AVANZADO</button>
</div>
</div>
</div><!-- /left-panel -->
<!-- CENTER: Map -->
<div id="map-wrap">
<div id="map"></div>
<!-- Zoom controls — overlay esquina superior derecha del mapa -->
<div id="map-zoom-ctrl">
<button class="map-zoom-btn" onclick="GPSMap.zoomIn()" title="Zoom in">+</button>
<button class="map-zoom-btn" onclick="GPSMap.zoomOut()" title="Zoom out"></button>
</div>
<div id="map-coords">LAT -- &nbsp; LON --</div>
<!-- Chart name under cursor (auto-show) -->
<div id="map-chart-info"></div>
</div>
<!-- RIGHT PANEL: Satellites + Feature Info -->
<div id="right-panel">
<div class="rp-title">SATELLITES</div>
<div class="canvas-wrap">
<canvas id="sky-canvas" width="258" height="258"></canvas>
<div id="sky-labels" class="canvas-labels"></div>
</div>
<div class="rp-title" style="margin-top:10px">SIGNAL</div>
<div class="canvas-wrap">
<canvas id="snr-canvas" width="258" height="150"></canvas>
<div id="snr-labels" class="canvas-labels"></div>
</div>
<div class="rp-sat-count">
<span id="sat-used">0</span> used / <span id="sat-view">0</span> in view
</div>
<!-- Feature / AIS info — aparece al hacer click en el mapa -->
<div id="rp-feat-info" style="display:none"></div>
</div>
</div>
</div><!-- /app -->
<!-- ── MODALS ─────────────────────────────────────────────────────────────── -->
<div id="modal-overlay" class="modal-overlay hidden">
<!-- Connect port -->
<div id="modal-connect" class="modal">
<div class="modal-header">GPS PORT<button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body">
<div class="form-field">
<label class="form-lbl">Port</label>
<select class="form-sel" id="sel-port"></select>
</div>
<div class="form-field">
<label class="form-lbl">Baud rate</label>
<select class="form-sel" id="sel-baud">
<option value="4800">4800</option>
<option value="9600" selected>9600</option>
<option value="38400">38400</option>
<option value="115200">115200</option>
</select>
</div>
<div class="modal-btns">
<button class="btn-primary" onclick="doConnect()">CONNECT</button>
<button class="btn-secondary" onclick="doDisconnect()">DISCONNECT</button>
<button class="btn-secondary" onclick="closeModal()">CANCEL</button>
</div>
</div>
</div>
<!-- Add / Edit waypoint -->
<div id="modal-wpt" class="modal">
<div class="modal-header">WAYPOINT<button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body">
<div class="form-field">
<label class="form-lbl">Name *</label>
<input class="form-inp" id="wpt-name" type="text" placeholder="WPT 001">
</div>
<div class="form-field">
<label class="form-lbl">Latitude</label>
<input class="form-inp" id="wpt-lat" type="number" step="0.00001" placeholder="10.51234">
</div>
<div class="form-field">
<label class="form-lbl">Longitude</label>
<input class="form-inp" id="wpt-lon" type="number" step="0.00001" placeholder="-74.80700">
</div>
<div class="form-field">
<label class="form-lbl">Notes</label>
<input class="form-inp" id="wpt-notes" type="text" placeholder="optional">
</div>
<input type="hidden" id="wpt-id">
<div class="modal-btns">
<button class="btn-primary" onclick="saveWpt()">SAVE</button>
<button class="btn-secondary" onclick="closeModal()">CANCEL</button>
</div>
</div>
</div>
<!-- New / Edit route -->
<div id="modal-route" class="modal">
<div class="modal-header">ROUTE<button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body">
<div class="form-field">
<label class="form-lbl">Name *</label>
<input class="form-inp" id="rte-name" type="text" placeholder="Route 1">
</div>
<div class="form-field">
<label class="form-lbl">Waypoints (select in order)</label>
<div id="rte-wpt-selector" class="wpt-selector"></div>
</div>
<input type="hidden" id="rte-id">
<div class="modal-btns">
<button class="btn-primary" onclick="saveRoute()">SAVE</button>
<button class="btn-secondary" onclick="closeModal()">CANCEL</button>
</div>
</div>
</div>
<!-- Chart management -->
<div id="modal-charts" class="modal hidden" style="max-width:500px">
<div class="modal-header">ENC CHARTS<button class="modal-close" onclick="closeModal()">×</button></div>
<div class="modal-body">
<div style="display:flex;gap:8px;margin-bottom:6px;align-items:center">
<label class="form-lbl" style="margin:0;white-space:nowrap">ENC File</label>
<button class="btn-primary" id="btn-upload-chart" onclick="uploadChart()" style="white-space:nowrap;flex:1">📁 OPEN .000 / .ZIP</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:4px;align-items:center">
<label class="form-lbl" style="margin:0;white-space:nowrap">📂 SD / Path</label>
<input type="text" id="chart-path-inp" placeholder="E:\ENC_Charts"
style="flex:1;font-size:0.72rem;color:var(--text);background:var(--bg3);border:1px solid var(--border);border-radius:3px;padding:3px 6px;font-family:monospace">
<button class="btn-primary" id="btn-scan-chart" onclick="scanChartsPath()" style="white-space:nowrap">SCAN</button>
</div>
<div style="font-size:0.68rem;color:var(--muted);margin-bottom:10px">
IHO S-57 ENC (.000) o NOAA/CIOH ZIP. Pega la ruta de la tarjeta SD para importar todo.
</div>
<div id="chart-cell-list" style="border-top:1px solid var(--border);padding-top:8px;max-height:260px;overflow-y:auto">
<div class="empty-list">No charts installed</div>
</div>
</div>
</div>
<!-- ── ENC Layer selector (AVANZADO) ──────────────────────────────────── -->
<div id="modal-enc-layers" class="modal hidden" style="max-width:340px">
<div class="modal-header">CAPAS ENC
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<div class="el-section-hdr">🌊 PROFUNDIDADES</div>
<div class="enc-layer-group">
<label class="enc-layer-lbl">
<input type="checkbox" id="el-depare" checked>
<div><span class="el-name">Áreas de profundidad</span><span class="el-desc">DEPARE — rellenos azules por rango de calado</span></div>
</label>
<label class="enc-layer-lbl">
<input type="checkbox" id="el-depcnt" checked>
<div><span class="el-name">Veriles / isobatas</span><span class="el-desc">DEPCNT — líneas de igual profundidad</span></div>
</label>
<label class="enc-layer-lbl">
<input type="checkbox" id="el-soundg">
<div><span class="el-name">Sondas</span><span class="el-desc">SOUNDG — valores numéricos de profundidad</span></div>
</label>
</div>
<div class="el-section-hdr">⚠️ PELIGROS Y ZONAS</div>
<div class="enc-layer-group">
<label class="enc-layer-lbl">
<input type="checkbox" id="el-hazards" checked>
<div><span class="el-name">Peligros</span><span class="el-desc">Naufragios, rocas sumergidas, obstrucciones</span></div>
</label>
<label class="enc-layer-lbl">
<input type="checkbox" id="el-zones">
<div><span class="el-name">Zonas náuticas</span><span class="el-desc">Restringidas, fondeo, tráfico separado</span></div>
</label>
</div>
<div class="el-section-hdr">🏝️ TIERRA Y COSTA</div>
<div class="enc-layer-group">
<label class="enc-layer-lbl">
<input type="checkbox" id="el-coalne" checked>
<div><span class="el-name">Línea de costa</span><span class="el-desc">COALNE — contorno de costa S-57</span></div>
</label>
<label class="enc-layer-lbl">
<input type="checkbox" id="el-landmask" checked>
<div><span class="el-name">Relleno de tierra</span><span class="el-desc">LANDMASK — color S-52 beige en áreas de tierra</span></div>
</label>
<label class="enc-layer-lbl">
<input type="checkbox" id="el-lndare">
<div><span class="el-name">Bordes polígono tierra</span><span class="el-desc">LNDARE — puede generar líneas diagonales</span></div>
</label>
<label class="enc-layer-lbl">
<input type="checkbox" id="el-buaare">
<div><span class="el-name">Zonas urbanas</span><span class="el-desc">BUAARE — límites de áreas edificadas</span></div>
</label>
</div>
<div class="el-section-hdr">🗺️ MAPA BASE</div>
<div class="enc-layer-group">
<label class="enc-layer-lbl">
<input type="checkbox" id="el-osm" checked>
<div><span class="el-name">Mapa OSM</span><span class="el-desc">OpenStreetMap — mapa base satelital/calles</span></div>
</label>
</div>
<div class="modal-btns" style="margin-top:14px">
<button class="btn-primary" onclick="applyEncLayers()">APLICAR</button>
<button class="btn-secondary" onclick="closeModal()">CANCELAR</button>
</div>
</div>
</div>
<!-- ── Selector de tipo de MARCA ─────────────────────────────────────── -->
<div id="modal-marca" class="modal hidden" style="max-width:380px">
<div class="modal-header">COLOCAR MARCA
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<p style="font-size:0.78rem;color:var(--muted);margin:0 0 8px">Selecciona el tipo de marca y haz click en el mapa:</p>
<div class="marca-grid" id="marca-type-grid">
<div class="marca-item" data-type="fishing" onclick="selectMarcaType(this)"><span class="marca-icon">🎣</span><span class="marca-label">Pesca</span></div>
<div class="marca-item" data-type="marina" onclick="selectMarcaType(this)"><span class="marca-icon"></span><span class="marca-label">Marina</span></div>
<div class="marca-item" data-type="fuel" onclick="selectMarcaType(this)"><span class="marca-icon"></span><span class="marca-label">Combustible</span></div>
<div class="marca-item" data-type="restaurant" onclick="selectMarcaType(this)"><span class="marca-icon">🍴</span><span class="marca-label">Restaurante</span></div>
<div class="marca-item" data-type="dive" onclick="selectMarcaType(this)"><span class="marca-icon">🤿</span><span class="marca-label">Buceo</span></div>
<div class="marca-item" data-type="anchorage" onclick="selectMarcaType(this)"><span class="marca-icon">🚢</span><span class="marca-label">Fondeo</span></div>
<div class="marca-item" data-type="beach" onclick="selectMarcaType(this)"><span class="marca-icon">🏖️</span><span class="marca-label">Playa</span></div>
<div class="marca-item" data-type="ramp" onclick="selectMarcaType(this)"><span class="marca-icon">🚤</span><span class="marca-label">Rampa</span></div>
<div class="marca-item" data-type="repair" onclick="selectMarcaType(this)"><span class="marca-icon">🔧</span><span class="marca-label">Taller</span></div>
<div class="marca-item" data-type="hospital" onclick="selectMarcaType(this)"><span class="marca-icon">🏥</span><span class="marca-label">Emergencia</span></div>
<div class="marca-item" data-type="customs" onclick="selectMarcaType(this)"><span class="marca-icon">🛂</span><span class="marca-label">Aduana</span></div>
<div class="marca-item" data-type="danger" onclick="selectMarcaType(this)"><span class="marca-icon">⚠️</span><span class="marca-label">Peligro</span></div>
<div class="marca-item" data-type="hotel" onclick="selectMarcaType(this)"><span class="marca-icon">🏨</span><span class="marca-label">Hotel</span></div>
<div class="marca-item" data-type="poi" onclick="selectMarcaType(this)"><span class="marca-icon">📍</span><span class="marca-label">Punto POI</span></div>
</div>
<div style="font-size:0.74rem;color:var(--muted);margin-top:6px" id="marca-type-hint">— Ningún tipo seleccionado —</div>
<div class="modal-btns" style="margin-top:10px">
<button class="btn-primary" id="btn-marca-ok" onclick="startMarcaDraw()" disabled>COLOCAR EN MAPA</button>
<button class="btn-secondary" onclick="closeModal()">CANCELAR</button>
</div>
</div>
</div>
<!-- ── Editar MARCA ───────────────────────────────────────────────────── -->
<div id="modal-mark" class="modal hidden" style="max-width:340px">
<div class="modal-header">EDITAR MARCA
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<input type="hidden" id="mark-id">
<input type="hidden" id="mark-type-val">
<div class="form-group">
<label class="form-label">Nombre</label>
<input type="text" id="mark-name" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Tipo</label>
<div id="mark-type-display" style="font-size:1.2rem;padding:4px 0"></div>
</div>
<div class="form-group">
<label class="form-label">Lat</label>
<input type="number" id="mark-lat" class="form-input" step="0.000001">
</div>
<div class="form-group">
<label class="form-label">Lon</label>
<input type="number" id="mark-lon" class="form-input" step="0.000001">
</div>
<div class="form-group">
<label class="form-label">Notas</label>
<textarea id="mark-notes" class="form-input" rows="2"></textarea>
</div>
<div class="modal-btns">
<button class="btn-primary" onclick="saveMark()">GUARDAR</button>
<button class="btn-secondary" onclick="closeModal()">CANCELAR</button>
</div>
</div>
</div>
</div><!-- /modal-overlay -->
<script src="https://cdn.jsdelivr.net/npm/ol@v9.2.4/dist/ol.js"></script>
<script src="js/skyplot.js"></script>
<script src="js/map.js"></script>
<script src="js/chart_layer.js"></script>
<script src="js/app.js"></script>
<!-- bridge.js must load LAST — it calls bootApp() once QWebChannel is ready -->
<script src="js/bridge.js"></script>
</body>
</html>
+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 };
})();
+109
View File
@@ -0,0 +1,109 @@
"""GPS Navigator — PyQt5 standalone application.
Reads NMEA from serial port, displays in embedded WebView.
Suitable for Raspberry Pi 4 or any desktop (Windows/Linux/macOS).
"""
import sys
import os
from pathlib import Path
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings, QWebEngineScript
from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtCore import QUrl, QFile, QIODevice
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent
DB_PATH = BASE_DIR / "data" / "gps.db"
from PyQt5.QtWebEngineWidgets import QWebEnginePage
from backend.database import init_db
from bridge import GPSBridge
class DebugPage(QWebEnginePage):
"""Re-envía mensajes de consola JS a stdout de Python."""
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
tag = ["DBG", "INF", "WRN", "ERR"][level] if level < 4 else "???"
src = sourceID.split("/")[-1] if sourceID else "?"
print(f"[JS:{tag}] {src}:{lineNumber} {message}")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("AR GPS Navigator")
_logo = BASE_DIR / "frontend" / "assets" / "images" / "ar_logo_full.png"
if _logo.exists():
self.setWindowIcon(QIcon(str(_logo)))
self.resize(1280, 800)
# ── Web view ──────────────────────────────────────────────────────────
self.view = QWebEngineView(self)
self.setCentralWidget(self.view)
page = DebugPage(self.view)
self.view.setPage(page)
# Settings — allow file:// to load local resources + touch
s = page.settings()
s.setAttribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls, True)
s.setAttribute(QWebEngineSettings.LocalContentCanAccessFileUrls, True)
s.setAttribute(QWebEngineSettings.JavascriptEnabled, True)
# TouchEventsEnabled no existe en todas las versiones de PyQt5 — omitir
# ── Bridge ────────────────────────────────────────────────────────────
self.bridge = GPSBridge(DB_PATH, parent=self)
# ── QWebChannel ───────────────────────────────────────────────────────
self.channel = QWebChannel(page)
self.channel.registerObject("py", self.bridge)
page.setWebChannel(self.channel)
# ── Inject qwebchannel.js from Qt internal resources ─────────────────
qwcf = QFile(":/qtwebchannel/qwebchannel.js")
if qwcf.open(QIODevice.ReadOnly):
qwc_js = bytes(qwcf.readAll()).decode("utf-8")
qwcf.close()
script = QWebEngineScript()
script.setName("qwebchannel.js")
script.setSourceCode(qwc_js)
script.setInjectionPoint(QWebEngineScript.DocumentCreation)
script.setWorldId(QWebEngineScript.MainWorld)
page.scripts().insert(script)
# ── Load frontend ─────────────────────────────────────────────────────
index_html = BASE_DIR / "frontend" / "index.html"
self.view.setUrl(QUrl.fromLocalFile(str(index_html)))
def closeEvent(self, event):
self.bridge.shutdown()
super().closeEvent(event)
def main():
# Needed on some Linux/RPi setups
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox")
(BASE_DIR / "charts").mkdir(exist_ok=True)
app = QApplication(sys.argv)
# Touch screen support
from PyQt5.QtCore import Qt
app.setAttribute(Qt.AA_SynthesizeTouchForUnhandledMouseEvents, False)
app.setApplicationName("GPS Navigator")
init_db(DB_PATH)
win = MainWindow()
win.show()
# GPS autodetect is triggered from JS (bootApp) once QWebChannel
# is ready — avoids the race condition where the "connected" signal
# would be emitted before the JS handler is registered.
sys.exit(app.exec_())
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
# GPS Navigator — standalone PyQt5 app
PyQt5>=5.15.0
PyQtWebEngine>=5.15.0
pyserial>=3.5
python-dotenv>=1.0.0
# Optional — only needed to parse .000 ENC files (heavy deps)
# geopandas>=0.14.0
# pyogrio>=0.6.0
+8
View File
@@ -0,0 +1,8 @@
@echo off
cd /d "%~dp0"
echo.
echo GPS Navigator ^| Standalone PyQt5 App
echo ========================================
echo.
python main.py
pause