feat: AR-GPS initial commit — Python + JavaScript PyQt5 (standalone desktop app) + FastAPI (charts REST router) + OpenLayers (frontend map)
This commit is contained in:
+36
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
@@ -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 |
@@ -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; }
|
||||
@@ -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 -- 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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user