commit 346bc1ffcbce0bca7018932b44e2d23756be252e Author: Alvaro Romero Date: Fri Jul 3 12:15:59 2026 -0400 feat: AR-GPS initial commit — Python + JavaScript PyQt5 (standalone desktop app) + FastAPI (charts REST router) + OpenLayers (frontend map) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad4b58c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chart_manager.py b/backend/chart_manager.py new file mode 100644 index 0000000..ba277d7 --- /dev/null +++ b/backend/chart_manager.py @@ -0,0 +1,1378 @@ +""" +S-57 ENC chart manager — parse, store and serve chart features as GeoJSON. +""" +import json +import logging +import math +import shutil +import tempfile +import zipfile +from pathlib import Path + +log = logging.getLogger(__name__) + +# Optional heavy deps — only needed for parsing .000 ENC files. +# GPS Navigator may not have them installed; CSV-based charts still work. +try: + import geopandas as gpd + import pandas as pd + _HAS_GPD = True +except ImportError: + gpd = None + pd = None + _HAS_GPD = False + log.warning("geopandas/pandas not installed — .000 ENC parsing disabled. " + "Install with: pip install geopandas pyogrio") + +CHARTS_DIR = Path(__file__).parent.parent / "charts" +CHARTS_DIR.mkdir(exist_ok=True) + +# S-57 layers we care about, mapped to a friendly category +S57_LAYERS = { + "LIGHTS": "light", + "BOYLAT": "buoy", + "BOYCAR": "buoy", + "BOYISD": "buoy", + "BOYSPP": "buoy", + "BOYSAW": "buoy", + "BCNLAT": "beacon", + "BCNCAR": "beacon", + "BCNISD": "beacon", + "BCNWTW": "beacon", + "LNDMRK": "landmark", + "RTPBCN": "beacon", + "LAKARE": "area", # Lake / lagoon / ciénaga area + # BUISGL is rendered as polygon footprint via _parse_land → landLayer. + # Removed from S57_LAYERS to avoid a duplicate centroid-point beacon dot. +} + +# Human-readable light character codes +LITCHR = { + 1:"F", 2:"Fl", 3:"LFl", 4:"Q", 5:"VQ", 6:"UQ", 7:"Iso", + 8:"Oc", 9:"IQ", 10:"IVQ", 11:"IUQ", 12:"Mo", 13:"FFl", + 14:"FlLFl", 15:"OcFl", 16:"FLFl", 25:"Al.Oc", 26:"Al.LFl", + 27:"Al.Fl", 28:"Al.Grp", +} + +COLOUR = { + 1:"W", 2:"K", 3:"R", 4:"G", 5:"B", 6:"Y", + 7:"Gy", 8:"Br", 9:"Amb", 10:"Vi", 11:"Or", 12:"Mg", +} + +BOYSHP = { + 1:"Conical(N)",2:"Can(C)",3:"Spherical",4:"Pillar", + 5:"Spar",6:"Barrel",7:"Super-buoy",8:"Ice buoy", +} + +# Cardinal category (CATCAM) per IHO S-57 Object Catalogue +CATCAM = {1:"N", 2:"E", 3:"S", 4:"W"} + +# IALA region defaults by chart producer prefix (cell ID first 2 chars) +REGION_A_PREFIXES = { + "GB","FR","DE","NL","BE","DK","NO","SE","FI","IS","IE","ES","PT","IT", + "GR","TR","RU","UA","HR","CY","MT","EE","LV","LT","PL","RO","BG","SI", + "MA","DZ","TN","LY","EG","ZA","NG","SN","CI","KE","TZ","MZ", + "IN","PK","BD","LK","MM","TH","ID","MY","SG","VN","CN","HK","TW", + "AE","SA","OM","IR","IQ","KW","QA","BH","YE", + "AU","NZ","PG", +} +REGION_B_PREFIXES = { + "US","CA","MX","BS","CU","JM","HT","DO","PR","VI", + "BZ","GT","SV","HN","NI","CR","PA", + "CO","VE","EC","PE","CL","AR","UY","BR","GY","SR","PY","BO", + "JP","KR","KP","PH", +} + +def auto_region(cell_id: str) -> str: + prefix = (cell_id or "").upper()[:2] + if prefix in REGION_A_PREFIXES: return "A" + if prefix in REGION_B_PREFIXES: return "B" + return "B" + + +# ── CSV → features (DIMAR / custom CSV charts) ──────────────────────────────── +# Mapping from DIMAR CSV feat_type to canonical S-57 layer name. +_CSV_FEAT_TYPE_MAP = { + "BOYSPEC": "BOYSPP", # DIMAR uses BOYSPEC; IHO S-57 is BOYSPP +} + +# Feature categories per layer +_CSV_CATEGORY_MAP = { + "BOYLAT": "buoy", "BOYCAR": "buoy", "BOYISD": "buoy", + "BOYSPP": "buoy", "BOYSAW": "buoy", + "BCNLAT": "beacon","BCNCAR": "beacon", + "LIGHTS": "light", "LNDMRK": "landmark", +} + + +def _csv_num(v): + if v is None or str(v).strip() == "": + return None + try: + return float(v) + except Exception: + return None + + +def _csv_int(v): + n = _csv_num(v) + return int(n) if n is not None else None + + +def _csv_fmt(v): + """Format float: drop trailing .0 for whole numbers.""" + try: + f = float(v) + return str(int(f)) if f == int(f) else str(f) + except Exception: + return str(v) + + +def _csv_light_desc(litchr, siggrp, colour, sigper, valnmr) -> str: + """Build compact light description from CSV fields (e.g. 'Fl G 3s 3M').""" + parts = [] + lc_int = _csv_int(litchr) + lc = LITCHR.get(lc_int, str(lc_int)) if lc_int is not None else "" + sg = _csv_int(siggrp) + if sg is not None: + lc = f"{lc}({sg})" + col_int = _csv_int(colour) + col_str = COLOUR.get(col_int, "") if col_int is not None else "" + if lc: + parts.append(f"{lc} {col_str}".strip()) + sp = _csv_num(sigper) + if sp is not None: + parts.append(f"{_csv_fmt(sp)}s") + rng = _csv_num(valnmr) + if rng is not None: + parts.append(f"{_csv_fmt(rng)}M") + return " ".join(parts) + + +def _csv_infer_catcam(siggrp: str, name: str) -> str | None: + """ + Cardinal buoy quadrant from SIGGRP (reliable) or DIMAR naming convention. + Q(9)+LFl=W Q(6)+LFl=S Q(3)=E Q=N + DIMAR name suffixes: SS/VS→S SN/VN→N SE→E SO→W + """ + sg = _csv_int(siggrp) + if sg is not None: + if sg == 9: return "W" + if sg == 6: return "S" + if sg == 3: return "E" + return "N" + n = (name or "").upper() + if any(x in n for x in (" SS", "(SS)", "VS")): return "S" + if any(x in n for x in (" SE", "(SE)")): return "E" + if any(x in n for x in (" SO", "(SO)")): return "W" + return "N" + + +def _csv_infer_catlam(colour: str) -> int | None: + """IALA B (Americas): green(4)=port=1, red(3)=stbd=2.""" + c = _csv_int(colour) + if c == 4: return 1 + if c == 3: return 2 + return None + + +def _parse_csvs_to_features(csv_dir: Path) -> list[dict]: + """ + Read navigation-aid CSV files from csv_dir and return GeoJSON features. + + Each CSV must have columns: OBJNAM, lon, lat, feat_type, LITCHR, SIGGRP, + COLOUR, SIGPER, VALNMR, HEIGHT, ORIENT, INFORM. + feat_type values: BOYLAT, BOYCAR, BOYISD, BOYSPEC/BOYSPP, BCNLAT, LIGHTS, + LNDMRK. + + This function is the primary path for DIMAR/custom charts created with + QGISS57Converter; it preserves all light attributes (LITCHR, SIGPER, etc.) + that the GDAL S-57 driver may drop during the .000 round-trip. + """ + import csv as _csv_mod + features: list[dict] = [] + for csv_file in sorted(csv_dir.glob("*.csv")): + with open(csv_file, newline="", encoding="utf-8") as fh: + reader = _csv_mod.DictReader(fh) + for row in reader: + feat_type = (row.get("feat_type") or csv_file.stem).strip() + layer = _CSV_FEAT_TYPE_MAP.get(feat_type, feat_type) + if not layer: + continue + lon = _csv_num(row.get("lon")) + lat = _csv_num(row.get("lat")) + if lon is None or lat is None: + continue + + category = _CSV_CATEGORY_MAP.get(layer, "buoy") + litchr = row.get("LITCHR", "").strip() + siggrp = row.get("SIGGRP", "").strip() + colour = row.get("COLOUR", "").strip() + sigper = row.get("SIGPER", "").strip() + valnmr = row.get("VALNMR", "").strip() + height = row.get("HEIGHT", "").strip() + orient_r = row.get("ORIENT", "").strip() + name = row.get("OBJNAM", "").strip() + inform = row.get("INFORM", "").strip() + + col_int = _csv_int(colour) + colours = [col_int] if col_int is not None else [] + light_d = _csv_light_desc(litchr, siggrp, colour, sigper, valnmr) + + props: dict = { + "layer": layer, + "category": category, + "name": name or None, + "info": inform or None, + "light_desc": light_d or None, + "range_nm": _csv_num(valnmr), + "height_m": _csv_num(height), + "colours": colours, + "colour_code": col_int, + } + if layer in ("BOYCAR", "BCNCAR"): + props["catcam"] = _csv_infer_catcam(siggrp, name) + if layer in ("BOYLAT", "BCNLAT"): + props["catlam"] = _csv_infer_catlam(colour) + orient_val = _csv_num(orient_r) + if orient_val is not None: + props["orient"] = orient_val + props["aid_type"] = classify(layer, props) + # Remove None values + props = {k: v for k, v in props.items() if v is not None} + features.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": props, + }) + return features + + +def install_from_csv_dir(csv_dir: Path, cell_id: str) -> str: + """ + Create or update an AidsMonitoring chart cell from a directory of CSV files. + + This is the preferred install pathway for custom (DIMAR) charts because it + preserves all S-57 attribute codes (LITCHR, SIGGRP, etc.) without loss. + + csv_dir — directory containing *.csv files (BOYLAT.csv, BOYCAR.csv, etc.) + cell_id — chart cell identifier (e.g. 'BAHIA_DE_CARTAGENA') + """ + cell_id = cell_id.upper() + cell_dir = CHARTS_DIR / cell_id + cell_dir.mkdir(exist_ok=True) + + features = _parse_csvs_to_features(csv_dir) + with open(cell_dir / "features.geojson", "w", encoding="utf-8") as f: + json.dump({"type": "FeatureCollection", "features": features}, f, + ensure_ascii=False) + log.info("CSV chart %s → %d features", cell_id, len(features)) + + # Empty auxiliary caches so the frontend doesn't show stale data + for fname in ("depths.geojson", "land.geojson", "hazards.geojson", "zones.geojson"): + p = cell_dir / fname + if not p.exists(): + with open(p, "w") as f: + json.dump({"type": "FeatureCollection", "features": []}, f) + + # Update meta.json + bbox = None + if features: + lons = [ft["geometry"]["coordinates"][0] for ft in features] + lats = [ft["geometry"]["coordinates"][1] for ft in features] + bbox = [min(lons), min(lats), max(lons), max(lats)] + meta = get_meta(cell_id) + meta["feature_count"] = len(features) + meta["bbox"] = bbox + meta.setdefault("region", auto_region(cell_id)) + _meta_path(cell_dir).write_text(json.dumps(meta)) + return cell_id + + +def install_from_csv_zip(zip_path: Path) -> list[str]: + """ + Install one or more CSV chart cells from a ZIP archive. + + Expected ZIP layout (any of these is accepted): + Option A — single cell (cell_id inferred from folder name or ZIP name): + BOYLAT.csv + BOYCAR.csv + ... + Option B — cell_id in subfolder: + BAHIA_DE_CARTAGENA/BOYLAT.csv + BAHIA_DE_CARTAGENA/BOYCAR.csv + Option C — meta.json declares cell_id: + meta.json → {"cell_id": "BAHIA_DE_CARTAGENA"} + BOYLAT.csv + ... + """ + import csv as _csv_mod + installed: list[str] = [] + with zipfile.ZipFile(zip_path) as z: + namelist = z.namelist() + + # Collect all CSV files grouped by subfolder + csv_files = [n for n in namelist if n.lower().endswith(".csv")] + if not csv_files: + raise ValueError("No CSV files found in ZIP") + + # Check for meta.json + meta_cell_id: str | None = None + if "meta.json" in namelist: + try: + meta_cell_id = json.loads(z.read("meta.json")).get("cell_id") + except Exception: + pass + + # Group by directory prefix + import collections + groups: dict[str, list[str]] = collections.defaultdict(list) + for name in csv_files: + prefix = str(Path(name).parent) + groups[prefix].append(name) + + with tempfile.TemporaryDirectory() as tmp_root: + tmp_root_p = Path(tmp_root) + for prefix, members in groups.items(): + # Determine cell_id + if meta_cell_id: + cid = meta_cell_id + elif prefix and prefix != ".": + cid = Path(prefix).name + else: + cid = Path(zip_path).stem # e.g. BAHIA_DE_CARTAGENA + # Extract CSVs to temp dir + tmp_csv = tmp_root_p / cid + tmp_csv.mkdir(exist_ok=True) + for member in members: + data = z.read(member) + (tmp_csv / Path(member).name).write_bytes(data) + # Install + result_id = install_from_csv_dir(tmp_csv, cid) + installed.append(result_id) + return installed + +def _meta_path(cell_dir: Path) -> Path: + return cell_dir / "meta.json" + +def get_meta(cell_id: str) -> dict: + cell_dir = CHARTS_DIR / cell_id.upper() + p = _meta_path(cell_dir) + if p.exists(): + try: return json.loads(p.read_text()) + except Exception: pass + return {} + +def set_meta(cell_id: str, **patch) -> dict: + cell_dir = CHARTS_DIR / cell_id.upper() + if not cell_dir.exists(): + raise FileNotFoundError(cell_id) + meta = get_meta(cell_id) + meta.update(patch) + _meta_path(cell_dir).write_text(json.dumps(meta)) + return meta + +def get_region(cell_id: str) -> str: + return get_meta(cell_id).get("region") or auto_region(cell_id) + +# ── Aid classification ──────────────────────────────────────────────────── +# Canonical aid types — derived from S-57 layer + attributes. +# Frontend dispatches symbol drawing on this; renders are region-aware +# only for LATERAL_* (which depend on IALA A vs B for color). +def classify(layer: str, props: dict) -> str: + catlam = props.get("catlam") + catcam = props.get("catcam") + if layer in ("BOYLAT", "BCNLAT"): + if catlam == 1: return "LATERAL_PORT" + if catlam == 2: return "LATERAL_STBD" + if catlam == 3: return "LATERAL_PREF_STBD" # preferred channel to STBD (acts port) + if catlam == 4: return "LATERAL_PREF_PORT" # preferred channel to PORT (acts stbd) + # Fallback: infer from primary colour (IALA B — Americas: green=port, red=stbd) + colour = props.get("colour_code") + if colour == 4: return "LATERAL_PORT" # green + if colour == 3: return "LATERAL_STBD" # red + return "LATERAL_UNKNOWN" + if layer in ("BOYCAR", "BCNCAR"): + if catcam == "N": return "CARDINAL_N" + if catcam == "E": return "CARDINAL_E" + if catcam == "S": return "CARDINAL_S" + if catcam == "W": return "CARDINAL_W" + return "CARDINAL_UNKNOWN" + if layer in ("BOYISD", "BCNISD"): return "ISOLATED_DANGER" + if layer == "BOYSAW": return "SAFE_WATER" + if layer == "BOYSPP": return "SPECIAL" + if layer == "LIGHTS": return "LIGHT_POINT" + if layer == "LNDMRK": return "LANDMARK" + if layer == "RTPBCN": return "RACON" + if layer.startswith("BCN"): return "BEACON_GENERIC" + if layer.startswith("BOY"): return "BUOY_GENERIC" + return "UNKNOWN" + +# Topmark shape (TOPSHP) +TOPSHP = { + 1:"cone_up",2:"cone_down",3:"sphere",4:"X",5:"cross", + 6:"can",7:"T",8:"triangle_up",9:"triangle_down", + 10:"circle",11:"rectangle",12:"board",13:"diamond", + 14:"diamond_2",15:"square",16:"rectangle_2",17:"pennant", + 18:"pennant_2",19:"pennant_3",20:"pennant_4", + 21:"2sphere",22:"2cone_up",23:"2cone_down", + 24:"cone_up_sphere",25:"2cone_base_up",26:"2cone_base_down", + 27:"2cone_point",28:"besom_up",29:"besom_down", + 30:"flag",31:"sphere_2",32:"2cone_base_apart", + 33:"board_2", +} + + +def _safe_list(val) -> list: + """Return a plain Python list from a pandas/numpy array-like value. + + GDAL/pyogrio surfaces multi-valued S-57 attributes (like COLOUR) as + comma-separated STRINGS (e.g. "4" or "1,4"), not as Python lists. + Without the string branch we silently drop colour info.""" + if val is None: + return [] + try: + if hasattr(val, 'tolist'): + val = val.tolist() + if isinstance(val, str): + return [int(x) for x in val.split(",") if x.strip()] + if isinstance(val, (list, tuple)): + return [int(v) for v in val if v is not None] + return [int(val)] + except Exception: + return [] + + +def _safe(val): + """Return a plain Python scalar from any pandas/numpy value.""" + if val is None: + return None + # numpy array or list with multiple elements — take first + if hasattr(val, '__len__') and not isinstance(val, str): + if len(val) == 0: + return None + val = val[0] + # numpy scalar + if hasattr(val, 'item'): + try: + val = val.item() + except Exception: + val = val.tolist() + # NaN check after extraction + try: + if isinstance(val, float) and _HAS_GPD and pd.isna(val): + return None + except Exception: + pass + return val + + +def _light_desc(row) -> str: + parts = [] + chr_code = _safe(row.get("LITCHR")) + grp = _safe(row.get("SIGGRP")) + col_list = _safe(row.get("COLOUR")) + period = _safe(row.get("SIGPER")) + rng = _safe(row.get("VALNMR")) + + lc = LITCHR.get(int(chr_code), str(chr_code)) if chr_code else "" + if grp: + lc = f"{lc}({grp})" + colours = "" + if col_list: + try: + nums = [int(x) for x in str(col_list).split(",") if x.strip()] + colours = ".".join(COLOUR.get(n, "?") for n in nums) + except Exception: + pass + if lc: + parts.append(f"{lc} {colours}".strip()) + if period: + parts.append(f"{period}s") + if rng: + parts.append(f"{rng}M") + return " ".join(parts) + + +def _parse_cell(enc_path: Path) -> list[dict]: + if not _HAS_GPD: + raise RuntimeError("geopandas not installed — cannot parse .000 ENC files. " + "Run: pip install geopandas pyogrio") + features = [] + + # ── Build land union for LDLINE clipping ───────────────────────────────── + # Leading lines must stop at the riverbank / land boundary, not extend + # indefinitely. We union all land polygons in the cell and use them to + # clip each LDLINE so it terminates where it hits the shore. + from shapely.ops import unary_union + from shapely.geometry import LineString as SLineString, Point as SPoint + _land_clip = None + for _ll in ("LNDARE", "BUAARE"): + try: + _gdf_l = gpd.read_file(str(enc_path), layer=_ll, engine="pyogrio") + if _gdf_l.empty: + continue + _gdf_l = _gdf_l.to_crs(epsg=4326) + _polys = [g for g in _gdf_l.geometry + if g is not None and not g.is_empty + and g.geom_type in ("Polygon", "MultiPolygon")] + if _polys: + _u = unary_union(_polys).buffer(0) + _land_clip = _u if _land_clip is None else _land_clip.union(_u) + except Exception: + pass + + for layer_name, category in S57_LAYERS.items(): + try: + gdf = gpd.read_file(str(enc_path), layer=layer_name, engine="pyogrio") + except Exception: + continue + if gdf.empty: + continue + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: + continue + # Use centroid for all geometry types + pt = geom.centroid if geom.geom_type != "Point" else geom + colours = _safe_list(row.get("COLOUR")) # e.g. [3,4] = Red+Green + props = { + "layer": layer_name, + "category": category, + "name": _safe(row.get("OBJNAM")), + "info": _safe(row.get("INFORM")), + "height_m": _safe(row.get("HEIGHT")), + "range_nm": _safe(row.get("VALNMR")), + "status": _safe(row.get("STATUS")), + "colours": colours, # list of S-57 colour codes + "colour_code": colours[0] if colours else None, # primary colour + } + # Extract light description for ALL navigational aids and lights. + # Previously only BOY* layers were included, so BCNLAT/BCNWTW/LNDMRK + # silently lost their LITCHR/SIGPER/VALNMR — fixed here. + if category in ("light", "buoy", "beacon", "landmark"): + props["light_desc"] = _light_desc(row) + if layer_name.startswith("BOY") or layer_name.startswith("BCN"): + shape = _safe(row.get("BOYSHP")) + props["boyshp"] = int(shape) if shape else None # raw code for JS + props["shape"] = BOYSHP.get(int(shape), None) if shape else None + topshp = _safe(row.get("TOPSHP")) + props["topshp"] = int(topshp) if topshp else None + if layer_name in ("BOYCAR", "BCNCAR"): + catcam = _safe(row.get("CATCAM")) + props["catcam"] = CATCAM.get(int(catcam), None) if catcam else None + if layer_name in ("BOYLAT", "BCNLAT"): + catlam = _safe(row.get("CATLAM")) # 1=port, 2=stbd, 3=pref-stbd, 4=pref-port + props["catlam"] = int(catlam) if catlam else None + # ORIENT — bearing of the leading line in True degrees + orient_raw = _safe(row.get("ORIENT")) + if orient_raw: + try: + props["orient"] = float(orient_raw) + except ValueError: + pass + props["aid_type"] = classify(layer_name, props) + features.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [pt.x, pt.y]}, + "properties": {k: v for k, v in props.items() if v is not None}, + }) + + # ── Leading-line bearing projection for lights with ORIENT ──────── + # Project a line from the mark in the ORIENT direction, then clip it + # against the land union so it terminates at the riverbank/shoreline. + # A long initial line (5 NM) is used so the clip always finds the shore. + if "orient" in props: + bearing_deg = props["orient"] + bearing_rad = math.radians(bearing_deg) + lat_rad = math.radians(pt.y) + cos_lat = math.cos(lat_rad) + # Initial generous length (5 NM) — will be clipped to land boundary + PROBE_NM = 5.0 + d_lat = (PROBE_NM / 60.0) * math.cos(bearing_rad) + d_lon = (PROBE_NM / 60.0) * math.sin(bearing_rad) / max(cos_lat, 1e-6) + end = [pt.x + d_lon, pt.y + d_lat] + + # Densify the probe line (10 intermediate points) so the clip is accurate + n_pts = 12 + ldcoords = [ + [pt.x + d_lon * i / n_pts, pt.y + d_lat * i / n_pts] + for i in range(n_pts + 1) + ] + + # Clip against land — the leading line must not cross land + if _land_clip is not None: + try: + probe = SLineString(ldcoords) + in_water = probe.difference(_land_clip) + if in_water.is_empty: + # Mark is on land — use minimum stub + ldcoords = [[pt.x, pt.y], end] + else: + # Keep only the segment that touches the mark + start_pt = SPoint(pt.x, pt.y) + geoms = (list(in_water.geoms) + if in_water.geom_type.startswith("Multi") + else [in_water]) + # Sort by distance of each segment's first point to mark + geoms.sort(key=lambda g: start_pt.distance(SPoint(g.coords[0]))) + # Take the first (nearest to mark) segment + seg = geoms[0] + ldcoords = [list(c[:2]) for c in seg.coords] + except Exception: + pass # fall through to unclipped coords + + features.append({ + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": ldcoords, + }, + "properties": { + "layer": "LDLINE", + "category": "ldline", + "aid_type": "LEADING_LINE", + "name": props.get("name", ""), + "orient": bearing_deg, + "colours": colours, + }, + }) + + # ── Proximity merge: copy light attrs from LIGHTS → co-located buoys/beacons ── + # In S-57 a LIGHTS object and a BOYLAT object may share the same coordinates. + # The LIGHTS carries LITCHR/VALNMR/HEIGHT/COLOUR; the BOYLAT carries BOYSHP/CATLAM. + # We find the nearest LIGHTS within 50 m and backfill missing attrs on the host. + MERGE_DEG = 0.00045 # ≈ 50 m at equator (1° lat ≈ 111 km) + light_feats = [f for f in features + if f["properties"].get("layer") == "LIGHTS" + and f["geometry"]["type"] == "Point"] + struct_feats = [f for f in features + if (f["properties"].get("layer","").startswith("BOY") + or f["properties"].get("layer","").startswith("BCN")) + and f["geometry"]["type"] == "Point"] + for lf in light_feats: + lc = lf["geometry"]["coordinates"] + lp = lf["properties"] + best_host, best_dist = None, MERGE_DEG + for bf in struct_feats: + bc = bf["geometry"]["coordinates"] + d = ((lc[0] - bc[0])**2 + (lc[1] - bc[1])**2) ** 0.5 + if d < best_dist: + best_dist, best_host = d, bf + if best_host: + hp = best_host["properties"] + if lp.get("light_desc") and not hp.get("light_desc"): + hp["light_desc"] = lp["light_desc"] + if lp.get("range_nm") and not hp.get("range_nm"): + hp["range_nm"] = lp["range_nm"] + if lp.get("height_m") and not hp.get("height_m"): + hp["height_m"] = lp["height_m"] + if lp.get("colours") and not hp.get("colours"): + hp["colours"] = lp["colours"] + hp["colour_code"] = lp.get("colour_code") + # Also sync orient for leading lines if the buoy didn't have it + if lp.get("orient") is not None and hp.get("orient") is None: + hp["orient"] = lp["orient"] + + return features + + +def _read_depth_unit(enc_path: Path) -> str: + """Read DSPM_DUNI from the S-57 dataset metadata. 1=METERS, 2=FEET, 3=FATHOMS.""" + try: + from osgeo import gdal + ds = gdal.OpenEx(str(enc_path)) + if ds: + md = ds.GetMetadata() or {} + duni = md.get("DSPM_DUNI") or md.get("S57_DUNI") + if duni: + return {"1": "METERS", "2": "FEET", "3": "FATHOMS"}.get(str(duni), "METERS") + except Exception as e: + log.debug("Could not read DUNI from %s: %s", enc_path, e) + return "METERS" # NOAA / IHO default + + +def _bbox_of_coords(coords) -> list[float]: + xs, ys = [], [] + def walk(c): + if isinstance(c[0], (int, float)): + xs.append(c[0]); ys.append(c[1]) + else: + for sub in c: walk(sub) + walk(coords) + return [min(xs), min(ys), max(xs), max(ys)] + + +def _parse_depths(enc_path: Path) -> list[dict]: + """Extract bathymetry from S-57 cell. + + SOUNDG: MultiPoint Z (sounding points; depth in geometry's Z value) + DEPCNT: LineString (contour at VALDCO meters) + DEPARE: Polygon (depth area between DRVAL1 and DRVAL2 meters) + + Each feature gets a `bbox` property [minx, miny, maxx, maxy] so the + /charts/depths endpoint can do fast viewport filtering without touching + the geometries. The depth unit (METERS/FEET/FATHOMS) read from the + cell's DSPM record is attached to every feature so the frontend legend + can display it without a separate request. + """ + if not _HAS_GPD: + return [] + out = [] + unit = _read_depth_unit(enc_path) # METERS / FEET / FATHOMS + + # Build a UNIFIED land mask from every S-57 layer that represents + # something a depth feature shouldn't cover. NOAA puts urban islands / + # reclaimed land in BUAARE, dykes in DAMCON, dry docks in DRYDOC, etc. + # — using only LNDARE leaves visible bleed. + from shapely.ops import unary_union + from shapely.prepared import prep + LAND_LAYERS = ("LNDARE", "BUAARE", "DAMCON", "DRYDOC", "PONTON", "LNDRGN") + land_polys = [] + for ll in LAND_LAYERS: + try: + gdf = gpd.read_file(str(enc_path), layer=ll, engine="pyogrio") + if gdf.empty: continue + gdf = gdf.to_crs(epsg=4326) + land_polys.extend(g for g in gdf.geometry + if g is not None and not g.is_empty + and g.geom_type in ("Polygon", "MultiPolygon")) + except Exception: + pass + land_union = None + land_prepared = None + if land_polys: + try: + land_union = unary_union(land_polys).buffer(0) + land_prepared = prep(land_union) + # Also emit the union as a LANDMASK feature so the frontend can + # paint land in the chart's own colour when OSM is hidden. + geoms = list(land_union.geoms) if land_union.geom_type.startswith("Multi") else [land_union] + for g in geoms: + if g.geom_type != "Polygon" or g.is_empty: continue + rings = [[list(c[:2]) for c in g.exterior.coords]] + rings.extend([list(c[:2]) for c in r.coords] for r in g.interiors) + out.append({ + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": rings}, + "properties": {"layer": "LANDMASK", "bbox": _bbox_of_coords(rings)}, + }) + except Exception as e: + log.warning("Land union failed for %s: %s", enc_path, e) + + # ── SOUNDG (drop sounding points that fall on land) ── + try: + gdf = gpd.read_file(str(enc_path), layer="SOUNDG", engine="pyogrio") + if not gdf.empty: + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + geoms = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + for g in geoms: + if not getattr(g, "has_z", False): continue + if land_prepared is not None and land_prepared.contains(g): + continue # sounding lies on land — skip + out.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [g.x, g.y]}, + "properties": {"layer": "SOUNDG", "depth": round(g.z, 1), + "unit": unit, + "bbox": [g.x, g.y, g.x, g.y]}, + }) + except Exception: + pass + + # ── DEPCNT (clip contour lines so they don't cross land) ── + try: + gdf = gpd.read_file(str(enc_path), layer="DEPCNT", engine="pyogrio") + if not gdf.empty: + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + depth = _safe(row.get("VALDCO")) + if land_union is not None and geom.intersects(land_union): + try: geom = geom.difference(land_union) + except Exception: pass + if geom.is_empty: continue + geoms = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + for g in geoms: + if g.geom_type != "LineString" or g.is_empty: continue + coords = [list(c[:2]) for c in g.coords] + if len(coords) < 2: continue + out.append({ + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": {"layer": "DEPCNT", + "depth": round(float(depth), 1) if depth is not None else None, + "unit": unit, + "bbox": _bbox_of_coords(coords)}, + }) + except Exception: + pass + + def _emit_polygon_feature(g, props): + """Write one Polygon (single-part) feature, with bbox + props.""" + if g.geom_type != "Polygon" or g.is_empty: return + rings = [[list(c[:2]) for c in g.exterior.coords]] + rings.extend([list(c[:2]) for c in r.coords] for r in g.interiors) + out.append({ + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": rings}, + "properties": {**props, "bbox": _bbox_of_coords(rings)}, + }) + + # ── DEPARE (clipped to remove land overlap) ── + try: + gdf = gpd.read_file(str(enc_path), layer="DEPARE", engine="pyogrio") + if not gdf.empty: + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + dmin = _safe(row.get("DRVAL1")) + dmax = _safe(row.get("DRVAL2")) + if land_union is not None and geom.intersects(land_union): + try: + geom = geom.difference(land_union) + except Exception: + pass + if geom.is_empty: continue + props = { + "layer": "DEPARE", + "depth_min": round(float(dmin), 1) if dmin is not None else None, + "depth_max": round(float(dmax), 1) if dmax is not None else None, + "unit": unit, + } + geoms = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + for g in geoms: + _emit_polygon_feature(g, props) + except Exception: + pass + + return out + + +def _emit_geometry(geom, props: dict) -> list[dict]: + """Flatten a shapely geometry into GeoJSON Feature dicts with bbox. + Handles Point / LineString / Polygon and their Multi* variants.""" + out = [] + geoms = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + for g in geoms: + if g.is_empty: + continue + if g.geom_type == "Point": + coords = [g.x, g.y] + gtype = "Point" + bbox = [g.x, g.y, g.x, g.y] + elif g.geom_type == "LineString": + coords = [list(c[:2]) for c in g.coords] + gtype = "LineString" + bbox = _bbox_of_coords(coords) + elif g.geom_type == "Polygon": + rings = [[list(c[:2]) for c in g.exterior.coords]] + rings += [[list(c[:2]) for c in r.coords] for r in g.interiors] + coords = rings + gtype = "Polygon" + bbox = _bbox_of_coords(coords) + else: + continue + out.append({ + "type": "Feature", + "geometry": {"type": gtype, "coordinates": coords}, + "properties": {**{k: v for k, v in props.items() if v is not None}, + "bbox": bbox}, + }) + return out + + +# ── Terrain / land features ─────────────────────────────────────────────────── +_LAND_POLY_LAYERS = {"LNDARE": "land_area", "BUAARE": "built_up_area", + "BUISGL": "building"} # building footprints as polygons +_LAND_LINE_LAYERS = {"COALNE": "coastline"} +_LAND_POINT_LAYERS = {"BRIDGE": "bridge", "HRBFAC": "harbour", + "SILTNK": "silo_tank"} + +def _split_coalne(geom, props: dict) -> list[dict]: + """Parte un COALNE LineString/MultiLineString en segmentos descartando + saltos artificiales (bordes de celda de carta que cruzan el océano). + + Un vértice-a-vértice de una costa real rara vez supera ~0.5° de arco + (~55 km). Si el salto supera MAX_DEG se considera borde artificial y se + corta ahí, emitiendo el segmento acumulado hasta ese punto. + """ + MAX_DEG = 0.5 # ~55 km — ajustar si aparecen cortes en costas rectas largas + + def _emit_seg(coords): + if len(coords) < 2: + return [] + bbox = _bbox_of_coords(coords) + return [{"type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": {**{k: v for k, v in props.items() if v is not None}, + "bbox": bbox}}] + + geoms = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + out = [] + for g in geoms: + if g.geom_type != "LineString": + continue + pts = [list(c[:2]) for c in g.coords] + seg = [pts[0]] + for i in range(1, len(pts)): + dx = abs(pts[i][0] - pts[i-1][0]) + dy = abs(pts[i][1] - pts[i-1][1]) + if dx > MAX_DEG or dy > MAX_DEG: + # salto artificial → emitir segmento acumulado y empezar uno nuevo + out.extend(_emit_seg(seg)) + seg = [pts[i]] + else: + seg.append(pts[i]) + out.extend(_emit_seg(seg)) + return out + + +def _parse_land(enc_path: Path) -> list[dict]: + if not _HAS_GPD: + return [] + out = [] + for layer_name, category in _LAND_POLY_LAYERS.items(): + try: + gdf = gpd.read_file(str(enc_path), layer=layer_name, engine="pyogrio") + except Exception: + continue + if gdf.empty: continue + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + props = {"layer": layer_name, "category": category, + "name": _safe(row.get("OBJNAM"))} + out.extend(_emit_geometry(geom, props)) + for layer_name, category in _LAND_LINE_LAYERS.items(): + try: + gdf = gpd.read_file(str(enc_path), layer=layer_name, engine="pyogrio") + except Exception: + continue + if gdf.empty: continue + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + props = {"layer": layer_name, "category": category, + "name": _safe(row.get("OBJNAM"))} + # COALNE Polygon → ignorar: LANDMASK ya cubre el área de tierra. + # COALNE LineString → partir en segmentos eliminando saltos largos (bordes + # artificiales de celda que crean líneas diagonales cruzando el océano). + if geom.geom_type in ("Polygon", "MultiPolygon"): + continue # artefacto: COALNE área → ya cubierto por LANDMASK + out.extend(_split_coalne(geom, props)) + for layer_name, category in _LAND_POINT_LAYERS.items(): + try: + gdf = gpd.read_file(str(enc_path), layer=layer_name, engine="pyogrio") + except Exception: + continue + if gdf.empty: continue + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + pt = geom.centroid if geom.geom_type != "Point" else geom + props = {"layer": layer_name, "category": category, + "name": _safe(row.get("OBJNAM")), + "height_m": _safe(row.get("HEIGHT"))} + if layer_name == "BRIDGE": + props["horclr"] = _safe(row.get("HORCLR")) + props["verclr"] = _safe(row.get("VERCLR")) + out.append({"type": "Feature", + "geometry": {"type": "Point", "coordinates": [pt.x, pt.y]}, + "properties": {k: v for k, v in props.items() if v is not None}}) + return out + + +# ── Navigational hazards ────────────────────────────────────────────────────── +_HAZARD_LAYERS = {"WRECKS": "wreck", "OBSTRN": "obstruction", "UWTROC": "rock"} + +def _parse_hazards(enc_path: Path) -> list[dict]: + if not _HAS_GPD: + return [] + out = [] + for layer_name, category in _HAZARD_LAYERS.items(): + try: + gdf = gpd.read_file(str(enc_path), layer=layer_name, engine="pyogrio") + except Exception: + continue + if gdf.empty: continue + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + pt = geom.centroid if geom.geom_type != "Point" else geom + props = {"layer": layer_name, "category": category, + "name": _safe(row.get("OBJNAM")), + "depth": _safe(row.get("VALSOU"))} + if layer_name == "WRECKS": props["catwrk"] = _safe(row.get("CATWRK")) + if layer_name == "UWTROC": props["catuwr"] = _safe(row.get("CATUWR")) + if layer_name == "OBSTRN": props["catobs"] = _safe(row.get("CATOBS")) + out.append({"type": "Feature", + "geometry": {"type": "Point", "coordinates": [pt.x, pt.y]}, + "properties": {k: v for k, v in props.items() if v is not None}}) + return out + + +# ── Navigation zones / areas ────────────────────────────────────────────────── +_ZONE_LAYERS = { + "RESARE": "restricted", "CTNARE": "caution", "ACHARE": "anchorage", + "TSSLPT": "traffic_lane","PRCARE": "precautionary","FAIRWY": "fairway", + "DMPGRD": "dumpground", "PIPARE": "pipeline_area", +} + +def _parse_zones(enc_path: Path) -> list[dict]: + if not _HAS_GPD: + return [] + out = [] + for layer_name, category in _ZONE_LAYERS.items(): + try: + gdf = gpd.read_file(str(enc_path), layer=layer_name, engine="pyogrio") + except Exception: + continue + if gdf.empty: continue + gdf = gdf.to_crs(epsg=4326) + for _, row in gdf.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: continue + props = {"layer": layer_name, "category": category, + "name": _safe(row.get("OBJNAM")), + "info": _safe(row.get("INFORM"))} + if layer_name == "RESARE": + props["catrea"] = _safe(row.get("CATREA")) + props["restrn"] = _safe_list(row.get("RESTRN")) + if layer_name == "ACHARE": + props["catach"] = _safe(row.get("CATACH")) + out.extend(_emit_geometry(geom, props)) + return out + + +def _ensure_meta(cell_id: str): + """Write meta.json with auto-detected region if not already set.""" + meta = get_meta(cell_id) + if "region" not in meta: + set_meta(cell_id, region=auto_region(cell_id)) + + +def install_from_zip(zip_path: Path) -> list[str]: + if not _HAS_GPD: + raise RuntimeError("geopandas not installed — cannot parse .000 ENC files. " + "Run: pip install geopandas pyogrio") + installed = [] + with zipfile.ZipFile(zip_path) as z: + enc_files = [n for n in z.namelist() if n.upper().endswith(".000")] + if not enc_files: + raise ValueError("No .000 ENC files found in ZIP") + for enc_name in enc_files: + cell_id = Path(enc_name).stem.upper() + cell_dir = CHARTS_DIR / cell_id + cell_dir.mkdir(exist_ok=True) + enc_dest = cell_dir / f"{cell_id}.000" + with z.open(enc_name) as src, open(enc_dest, "wb") as dst: + shutil.copyfileobj(src, dst) + _build_cache(cell_id, enc_dest) + _ensure_meta(cell_id) + installed.append(cell_id) + return installed + + +def _read_cell_id(enc_path: Path) -> str: + """Lee el nombre de celda real del header DSID del S-57. + Si el archivo tiene nombre temporal (TMP*), usa DSID_DSNM o DSID_DSNM como fallback.""" + stem = enc_path.stem.upper() + # Si el nombre ya parece un cell ID válido (no empieza con TMP), usarlo directamente + if not stem.startswith("TMP"): + return stem + # Intentar leer DSNM del header S-57 via GDAL + try: + from osgeo import gdal + ds = gdal.OpenEx(str(enc_path)) + if ds: + md = ds.GetMetadata() or {} + dsnm = (md.get("DSID_DSNM") or "").strip().upper() + if dsnm and not dsnm.startswith("TMP"): + return dsnm + except Exception as e: + log.debug("Could not read DSNM from %s: %s", enc_path, e) + # Fallback: usar el stem aunque sea TMP (se mostrará en lista) + return stem + + +def install_from_enc(enc_path: Path, orig_name: str | None = None) -> str: + if not _HAS_GPD: + raise RuntimeError("geopandas not installed — cannot parse .000 ENC files. " + "Run: pip install geopandas pyogrio") + # Preferir el nombre original del archivo (sin extensión) como cell_id. + # Si no viene o empieza con TMP, intentar leerlo del header S-57. + if orig_name and not orig_name.upper().startswith("TMP"): + cell_id = orig_name.upper() + else: + cell_id = _read_cell_id(enc_path) + cell_dir = CHARTS_DIR / cell_id + cell_dir.mkdir(exist_ok=True) + enc_dest = cell_dir / f"{cell_id}.000" + shutil.copy2(enc_path, enc_dest) + _build_cache(cell_id, enc_dest) + _ensure_meta(cell_id) + return cell_id + + +def _build_cache(cell_id: str, enc_path: Path): + log.info("Parsing ENC %s …", cell_id) + cell_dir = CHARTS_DIR / cell_id + + features = _parse_cell(enc_path) + with open(cell_dir / "features.geojson", "w") as f: + json.dump({"type": "FeatureCollection", "features": features}, f) + log.info("ENC %s → %d aid features", cell_id, len(features)) + + depths = _parse_depths(enc_path) + with open(cell_dir / "depths.geojson", "w") as f: + json.dump({"type": "FeatureCollection", "features": depths}, f) + log.info("ENC %s → %d depth features", cell_id, len(depths)) + + land = _parse_land(enc_path) + land_path = cell_dir / "land.geojson" + # Preserve existing land cache when the .000 has no LNDARE layer + # (e.g. custom charts built from CSV that only contain nav aids) + if land or not land_path.exists(): + with open(land_path, "w") as f: + json.dump({"type": "FeatureCollection", "features": land}, f) + log.info("ENC %s → %d land features%s", cell_id, len(land), + " (preserved existing)" if not land and land_path.exists() else "") + + hazards = _parse_hazards(enc_path) + hazards_path = cell_dir / "hazards.geojson" + if hazards or not hazards_path.exists(): + with open(hazards_path, "w") as f: + json.dump({"type": "FeatureCollection", "features": hazards}, f) + log.info("ENC %s → %d hazard features", cell_id, len(hazards)) + + zones = _parse_zones(enc_path) + zones_path = cell_dir / "zones.geojson" + if zones or not zones_path.exists(): + with open(zones_path, "w") as f: + json.dump({"type": "FeatureCollection", "features": zones}, f) + log.info("ENC %s → %d zone features", cell_id, len(zones)) + + # Cache count and bbox in meta.json so list_cells() doesn't need to read features.geojson + # Solo usa features de tipo Point para el bbox — los LDLINE (LineString) tienen + # coordinates[0] = [lon, lat] (lista), no un float, y rompen min(lons). + bbox = None + try: + lons, lats = [], [] + for f in features: + geom = f.get("geometry", {}) + if geom.get("type") == "Point": + coords = geom.get("coordinates", []) + if len(coords) >= 2: + lons.append(float(coords[0])) + lats.append(float(coords[1])) + if lons: + bbox = [min(lons), min(lats), max(lons), max(lats)] + except Exception: + pass + meta = get_meta(cell_id) + meta["feature_count"] = len(features) + meta["bbox"] = bbox + _meta_path(cell_dir).write_text(json.dumps(meta)) + + +def list_cells() -> list[dict]: + cells = [] + for cell_dir in sorted(CHARTS_DIR.iterdir()): + if not cell_dir.is_dir(): + continue + # Skip leftover temp directories from failed installs + if cell_dir.name.startswith("TMP"): + continue + enc = list(cell_dir.glob("*.000")) + cache = cell_dir / "features.geojson" + meta = get_meta(cell_dir.name) + count = meta.get("feature_count") + bbox = meta.get("bbox") + + # Recompute bbox si es None (celdas instaladas con código anterior que tenía + # el bug LDLINE: coordinates[0] de un LineString es [lon,lat] no float → + # min(lons) fallaba → bbox quedaba None → celdas excluidas del viewport). + # Solo usa features Point para el bbox y persiste en meta.json para la próxima vez. + if bbox is None and cache.exists(): + try: + fc = json.loads(cache.read_text()) + feats = fc.get("features") or [] + if count is None: + count = len(feats) + lons, lats = [], [] + for f in feats: + geom = f.get("geometry", {}) + if geom.get("type") == "Point": + coords = geom.get("coordinates", []) + if len(coords) >= 2: + lons.append(float(coords[0])) + lats.append(float(coords[1])) + if lons: + bbox = [min(lons), min(lats), max(lons), max(lats)] + # Persistir para que las siguientes llamadas sean rápidas + meta["bbox"] = bbox + if count is not None: + meta["feature_count"] = count + try: + _meta_path(cell_dir).write_text(json.dumps(meta)) + except Exception: + pass + except Exception: + if count is None: + count = 0 + + if count is None: + count = 0 + + cells.append({ + "id": cell_dir.name, + "enc_file": enc[0].name if enc else None, + "features": count or 0, + "cached": cache.exists(), + "bbox": bbox, + "region": get_region(cell_dir.name), + }) + return cells + + +def delete_cell(cell_id: str): + cell_dir = CHARTS_DIR / cell_id.upper() + if cell_dir.exists(): + shutil.rmtree(cell_dir) + + +def scan_and_install(directory: str | Path) -> dict: + """ + Walk *directory* (local path — e.g. an SD card drive letter like E:\\) + and install every .000 ENC cell and every .zip archive found. + + Returns: + { + "installed": ["CELL1", "CELL2", ...], # successfully installed + "skipped": ["already_there.000"], # already installed (skipped) + "errors": [{"file": "...", "error": "..."}] # parse failures + } + """ + base = Path(directory) + if not base.exists(): + raise FileNotFoundError(f"Path not found: {base}") + if not base.is_dir(): + raise NotADirectoryError(f"Not a directory: {base}") + + installed: list[str] = [] + skipped: list[str] = [] + errors: list[dict] = [] + + # Collect candidates — full recursive walk of the directory tree. + # Typical SD card layouts: ENC_ROOT/*.000, ENC_ROOT/US5NY01M/*.000, + # NOAA_ENC/US5NY01M/US5NY01M.000, etc. + candidates: list[Path] = [] + for entry in base.rglob("*"): + if entry.is_file(): + candidates.append(entry) + + # Process .zip first (may contain multiple cells), then .000 + zips = [c for c in candidates if c.suffix.lower() == ".zip"] + encs = [c for c in candidates if c.suffix.upper() == ".000"] + + existing_ids = {cell_dir.name.upper() + for cell_dir in CHARTS_DIR.iterdir() if cell_dir.is_dir()} + + for zp in zips: + try: + import zipfile as _zf + with _zf.ZipFile(zp) 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: + ids = install_from_csv_zip(zp) + else: + ids = install_from_zip(zp) + installed.extend(ids) + except Exception as exc: + errors.append({"file": zp.name, "error": str(exc)}) + + for ep in encs: + cid = ep.stem.upper() + if cid in existing_ids: + skipped.append(ep.name) + continue + try: + cid = install_from_enc(ep, ep.stem.upper()) + installed.append(cid) + except Exception as exc: + errors.append({"file": ep.name, "error": str(exc)}) + + return {"installed": installed, "skipped": skipped, "errors": errors} + + +def get_all_features() -> dict: + all_features = [] + for cell_dir in CHARTS_DIR.iterdir(): + cache = cell_dir / "features.geojson" + if not cache.exists(): + continue + try: + fc = json.loads(cache.read_text()) + except Exception: + continue + cell_id = cell_dir.name + region = get_region(cell_id) + for f in (fc.get("features") or []): + p = f.setdefault("properties", {}) + p["cell"] = cell_id + p["cell_region"] = region + # Backfill aid_type for old caches that pre-date the classifier. + if "aid_type" not in p: + p["aid_type"] = classify(p.get("layer", ""), p) + all_features.extend(fc["features"]) + return {"type": "FeatureCollection", "features": all_features} + + +def _aggregate_cache(filename: str, bbox=None) -> dict: + """Generic aggregator: read from every installed cell.""" + all_features = [] + if bbox is not None: + w, s, e, n = bbox + for cell_dir in CHARTS_DIR.iterdir(): + cache = cell_dir / filename + if not cache.exists(): + continue + try: + fc = json.loads(cache.read_text()) + except Exception: + continue + for f in (fc.get("features") or []): + p = f.setdefault("properties", {}) + p["cell"] = cell_dir.name + if bbox is not None: + fb = p.get("bbox") + if fb and (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n): + continue + all_features.append(f) + return {"type": "FeatureCollection", "features": all_features} + + +def get_all_depths(bbox: tuple[float, float, float, float] | None = None) -> dict: + return _aggregate_cache("depths.geojson", bbox) + +def get_all_land(bbox: tuple[float, float, float, float] | None = None) -> dict: + return _aggregate_cache("land.geojson", bbox) + +def get_all_hazards() -> dict: + return _aggregate_cache("hazards.geojson") + +def get_all_zones(bbox: tuple[float, float, float, float] | None = None) -> dict: + return _aggregate_cache("zones.geojson", bbox) diff --git a/backend/charts_router.py b/backend/charts_router.py new file mode 100644 index 0000000..8179c80 --- /dev/null +++ b/backend/charts_router.py @@ -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 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..c08b588 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/nmea_reader.py b/backend/nmea_reader.py new file mode 100644 index 0000000..939d887 --- /dev/null +++ b/backend/nmea_reader.py @@ -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}) diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..fba7b4a --- /dev/null +++ b/bridge.py @@ -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}) diff --git a/frontend/assets/brand/brand_colors.json b/frontend/assets/brand/brand_colors.json new file mode 100644 index 0000000..797895e --- /dev/null +++ b/frontend/assets/brand/brand_colors.json @@ -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" + } +} diff --git a/frontend/assets/images/ar_logo_full.png b/frontend/assets/images/ar_logo_full.png new file mode 100644 index 0000000..42ad7e9 Binary files /dev/null and b/frontend/assets/images/ar_logo_full.png differ diff --git a/frontend/css/main.css b/frontend/css/main.css new file mode 100644 index 0000000..1e4fd2b --- /dev/null +++ b/frontend/css/main.css @@ -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; } diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..207be1e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,485 @@ + + + + + + GPS Navigator + + + + + + +
+ + +
+ +
+ + GPSNAVIGATOR +
+ + + + +
+ + NO GPS +
+ + + NO FIX + + + + + +
+ + +
+ + + + +
+ + + + +
--:--:-- UTC
+
+ + +
+ + +
+ + +
+ + + + + +
+ + +
+ +
+
POSITION
+
--°--'-.--
+
--°--'-.--
+
+ +
+
+
SOG
--
+
COG
--
+
+
+
COG MAG
--
+
MAGVAR
--
+
+
+ +
+
+
ALT
--
+
FIX
--
+
+
+
HDOP
--
+
VDOP
--
+
+
+
PDOP
--
+
SATS
--
+
+
+ + +
+
SENSORS
+
+
HDG TRUE
--
+
HDG MAG
--
+
+
+
DEPTH
--
+
TEMP
--
+
+
+ + + + +
+ + + + + + + + + + + + + + +
+
MAP TOOLS
+ + +
+ + + + + + + + +
+ +
CARTAS ENC
+
+ + + +
+
+ +
+ + +
+
+ + +
+ + +
+ +
LAT --   LON --
+ +
+
+ + +
+
SATELLITES
+
+ +
+
+
SIGNAL
+
+ +
+
+
+ 0 used / 0 in view +
+ + + +
+ +
+ +
+ + + + + + + + + + + + + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..8d60614 --- /dev/null +++ b/frontend/js/app.js @@ -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 = '
No waypoints saved
'; + 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 ` +
+
${w.name}
+
${_fmtDM(w.lat,'N','S')} ${_fmtDM(w.lon,'E','W')}
+ ${dist ? `
${brg} ${dist}
` : ''} +
+ + + + +
+
`; + }).join(''); +} + +function _renderMarksList() { + var el = document.getElementById('mark-list'); + if (!el) return; + if (!_marks.length) { + el.innerHTML = '
No hay marcas guardadas
'; + 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 '
' + + '' + emoji + '' + + '
' + + '
' + m.name + '
' + + '
' + _fmtDM(m.lat,'N','S') + ' ' + _fmtDM(m.lon,'E','W') + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }).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 = '
No routes saved
'; + 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 ` +
+
${r.name}
+
${wpts}
+
+ +
+
`; + }).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 => ` + `).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, '''); +} + +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(`${_escapeHtml(line)}`); + 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 => ``).join('') + : ''; + 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 = '
Bridge not ready — restart app
'; + return; + } + try { + _chartCells = JSON.parse(await _py('get_chart_cells')); + _renderChartCells(); + } catch(e) { + console.error('[charts] refresh failed:', e); + if (el) el.innerHTML = '
Error: ' + e + '
'; + } +} + +function _renderChartCells() { + const el = document.getElementById('chart-cell-list'); + if (!el) return; + if (!_chartCells.length) { + el.innerHTML = '
No charts installed
'; + return; + } + el.innerHTML = _chartCells.map(c => { + const bbox = c.bbox ? c.bbox.map(v => v.toFixed(2)).join(', ') : '--'; + return ` +
+
+
+ ${c.id} + ${c.features} features +
+
+ + +
+
+
bbox: ${bbox}
+
`; + }).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(); +}; diff --git a/frontend/js/bridge.js b/frontend/js/bridge.js new file mode 100644 index 0000000..a0a557c --- /dev/null +++ b/frontend/js/bridge.js @@ -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 + // 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(); + } + +})(); diff --git a/frontend/js/chart_layer.js b/frontend/js/chart_layer.js new file mode 100644 index 0000000..aae1885 --- /dev/null +++ b/frontend/js/chart_layer.js @@ -0,0 +1,1498 @@ +'use strict'; +/* S-57 ENC chart layer for GPS Navigator. + Attaches to GPSMap.getOLMap() and exposes window.ChartLayer. */ + +const ChartLayer = (function () { + + // ── Canvas symbol cache ────────────────────────────────────────────────── + const _cache = {}; + function _ci(key, fn) { if (!_cache[key]) _cache[key] = fn(); return _cache[key]; } + + // ── S-57 colour code → CSS colour ──────────────────────────────────────── + const S57_CSS = { + 1:'#ffffff', 2:'#111111', 3:'#e53935', 4:'#2e7d32', + 5:'#1565c0', 6:'#f9a825', 7:'#78909c', 8:'#6d4c41', + 9:'#ff8f00', 10:'#7b1fa2', 11:'#ef6c00', 12:'#e91e63', + }; + function _s57css(c) { return S57_CSS[c] || '#78909c'; } + + // ── Canvas helpers ─────────────────────────────────────────────────────── + function _mkC(sz) { + const c = document.createElement('canvas'); c.width = sz; c.height = sz; return c; + } + function _wl(ctx, cx, y, hw) { + ctx.strokeStyle = '#1a6bb5'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(cx - hw, y); ctx.lineTo(cx + hw, y); ctx.stroke(); + } + function _st(ctx, cx, y1, y2) { + ctx.strokeStyle = '#222'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(cx, y1); ctx.lineTo(cx, y2); ctx.stroke(); + } + function _tmConeUp(ctx, cx, y0, w, h, fill) { + ctx.beginPath(); ctx.moveTo(cx, y0); ctx.lineTo(cx+w/2, y0+h); ctx.lineTo(cx-w/2, y0+h); ctx.closePath(); + ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); + } + function _tmConeDown(ctx, cx, y0, w, h, fill) { + ctx.beginPath(); ctx.moveTo(cx-w/2, y0); ctx.lineTo(cx+w/2, y0); ctx.lineTo(cx, y0+h); ctx.closePath(); + ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); + } + function _tmSphere(ctx, cx, cy, r, fill) { + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); + ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); + } + function _tmX(ctx, cx, cy, r, col) { + ctx.strokeStyle = col; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(cx-r, cy-r); ctx.lineTo(cx+r, cy+r); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx+r, cy-r); ctx.lineTo(cx-r, cy+r); ctx.stroke(); + } + + // ── 3D helpers ─────────────────────────────────────────────────────────── + function _h2r(hex) { + hex = (hex || '').replace('#',''); + if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; + if (hex.length < 6) return [120, 144, 156]; // fallback gris neutro — evita NaN + const r = parseInt(hex.slice(0,2),16); + const g = parseInt(hex.slice(2,4),16); + const b = parseInt(hex.slice(4,6),16); + return [isNaN(r)?120:r, isNaN(g)?144:g, isNaN(b)?156:b]; + } + function _lighten(hex, f=0.45) { + const [r,g,b] = _h2r(hex); + return `rgb(${Math.min(255,Math.round(r+(255-r)*f))},${Math.min(255,Math.round(g+(255-g)*f))},${Math.min(255,Math.round(b+(255-b)*f))})`; + } + function _darken(hex, f=0.45) { + const [r,g,b] = _h2r(hex); + return `rgb(${Math.round(r*(1-f))},${Math.round(g*(1-f))},${Math.round(b*(1-f))})`; + } + function _cylGrad(ctx, x, w, yMid, col) { + const g = ctx.createLinearGradient(x, yMid, x+w, yMid); + g.addColorStop(0, _lighten(col,0.58)); g.addColorStop(0.22, _lighten(col,0.18)); + g.addColorStop(0.55, col); g.addColorStop(0.82, _darken(col,0.32)); + g.addColorStop(1, _darken(col,0.55)); return g; + } + function _coneGrad(ctx, x, w, yMid, col) { + const g = ctx.createLinearGradient(x, yMid, x+w, yMid); + g.addColorStop(0, _lighten(col,0.65)); g.addColorStop(0.20, _lighten(col,0.20)); + g.addColorStop(0.55, col); g.addColorStop(1, _darken(col,0.60)); return g; + } + function _fillBands3D(ctx, pathFn, bx, by, bw, bh, css, gradFn) { + ctx.save(); pathFn(ctx); ctx.clip(); + if (!css.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx,by,bw,bh); } + else { + const bandH = bh / css.length; + css.forEach((col,i) => { + const y0 = by + i*bandH; + ctx.fillStyle = gradFn(ctx, bx, bw, y0+bandH/2, col); + ctx.fillRect(bx, y0, bw, bandH+1); + }); + } + ctx.restore(); + } + + function _ialaLateralColours(catlam, region) { + const ialaB = (region||'B') === 'B'; + const G=4, R=3; + const port = ialaB ? G : R, stbd = ialaB ? R : G; + if (catlam===1) return [port]; if (catlam===2) return [stbd]; + if (catlam===3) return [port,stbd,port]; if (catlam===4) return [stbd,port,stbd]; + return []; + } + + // ── S-52 light flare ───────────────────────────────────────────────────── + function _drawLightFlare(ctx, cx, y, col) { + const MAG='#ff00aa', len=8; + ctx.save(); ctx.translate(cx, y); + ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(len*0.95,-len*0.30); ctx.lineTo(len*0.30,-len*0.95); ctx.closePath(); + ctx.fillStyle = MAG; ctx.fill(); + ctx.strokeStyle='#fff'; ctx.lineWidth=0.5; ctx.stroke(); + ctx.beginPath(); ctx.arc(0,0,1.5,0,Math.PI*2); ctx.fillStyle=MAG; ctx.fill(); + if (col && col!==MAG) { + ctx.beginPath(); ctx.arc(len*0.62,-len*0.62,1.8,0,Math.PI*2); + ctx.fillStyle=col; ctx.fill(); + ctx.strokeStyle='#fff'; ctx.lineWidth=0.4; ctx.stroke(); + } + ctx.restore(); + } + + // ── 3D buoy canvas ─────────────────────────────────────────────────────── + function _encBuoyCanvas(opts) { + const sz = opts.sz || 52; + const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz/2; + let cc = (opts.colours||[]).slice(); + if (!cc.length && opts.catlam) cc = _ialaLateralColours(opts.catlam, opts.region); + const css = cc.map(_s57css); + const c1 = css[0] || '#78909c'; + const wlY=sz*0.92, bBot=sz*0.88, bTop=sz*0.52, bW=sz*0.52, bH=bBot-bTop; + const eRy=bH*0.095; + const _tmRoom=sz*0.52*0.40; + const stTop = opts.hasLight ? Math.max(sz*0.20,_tmRoom+sz*0.03) : Math.max(sz*0.24,_tmRoom+sz*0.03); + const glowCol = c1 ? _lighten(c1,0.55) : 'rgba(255,255,255,0.7)'; + const outln = (lw=0.9) => { ctx.shadowBlur=0; ctx.strokeStyle='rgba(0,0,0,0.75)'; ctx.lineWidth=lw; ctx.stroke(); }; + + const drawCylinder = (x, topY, w, h, colList) => { + const ery=h*0.095; + _fillBands3D(ctx, cc=>{ cc.beginPath(); cc.rect(x,topY,w,h); }, x, topY, w, h, colList, _cylGrad); + ctx.shadowBlur=5; ctx.shadowColor=glowCol; + ctx.strokeStyle='rgba(255,255,255,0.45)'; ctx.lineWidth=1.0; ctx.strokeRect(x,topY,w,h); + ctx.shadowBlur=0; ctx.strokeStyle='rgba(0,0,0,0.55)'; ctx.lineWidth=0.6; ctx.strokeRect(x,topY,w,h); + ctx.beginPath(); ctx.ellipse(x+w/2,topY+h,w/2,ery,0,0,Math.PI*2); + ctx.fillStyle=_darken(colList[colList.length-1]||c1,0.5); ctx.fill(); outln(0.5); + const tg=ctx.createRadialGradient(x+w*0.3,topY,1,x+w/2,topY,w/2); + tg.addColorStop(0,'rgba(255,255,255,0.80)'); tg.addColorStop(0.5,_lighten(colList[0]||c1,0.25)); tg.addColorStop(1,colList[0]||c1); + ctx.beginPath(); ctx.ellipse(x+w/2,topY,w/2,ery,0,0,Math.PI*2); + ctx.fillStyle=tg; ctx.fill(); outln(0.5); + }; + const drawCone = (cx2, apexY, baseY, w, colList) => { + const ery=(baseY-apexY)*0.10, bx=cx2-w/2; + _fillBands3D(ctx, cc=>{ cc.beginPath(); cc.moveTo(cx2,apexY); cc.lineTo(cx2+w/2,baseY); cc.lineTo(cx2-w/2,baseY); cc.closePath(); }, + bx,apexY,w,baseY-apexY,colList,_coneGrad); + ctx.beginPath(); ctx.moveTo(cx2,apexY); ctx.lineTo(cx2+w/2,baseY); ctx.lineTo(cx2-w/2,baseY); ctx.closePath(); + ctx.shadowBlur=5; ctx.shadowColor=glowCol; ctx.strokeStyle='rgba(255,255,255,0.40)'; ctx.lineWidth=1.0; ctx.stroke(); ctx.shadowBlur=0; + ctx.beginPath(); ctx.moveTo(cx2,apexY); ctx.lineTo(cx2+w/2,baseY); ctx.lineTo(cx2-w/2,baseY); ctx.closePath(); outln(0.9); + ctx.beginPath(); ctx.ellipse(cx2,baseY,w/2,ery,0,0,Math.PI*2); + ctx.fillStyle=_darken(colList[colList.length-1]||c1,0.40); ctx.fill(); outln(0.5); + }; + const drawSphere = (cx2, cy2, r, colList) => { + ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); ctx.fillStyle=colList[0]||'#78909c'; ctx.fill(); + ctx.save(); ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); ctx.clip(); + const rg=ctx.createRadialGradient(cx2-r*0.38,cy2-r*0.38,r*0.04,cx2,cy2,r); + rg.addColorStop(0,'rgba(255,255,255,0.72)'); rg.addColorStop(0.30,'rgba(255,255,255,0.18)'); + rg.addColorStop(0.65,'rgba(0,0,0,0.00)'); rg.addColorStop(1,'rgba(0,0,0,0.42)'); + ctx.fillStyle=rg; ctx.fillRect(cx2-r,cy2-r,r*2,r*2); ctx.restore(); + ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); outln(0.8); + }; + const drawTopmarkCan = (tx,ty,w,col) => { const h=w; drawCylinder(tx-w/2,ty-h,w,h,[col]); }; + const drawTopmarkCone = (tx,ty,w,col) => { const h=w*0.9; drawCone(tx,ty-h,ty,w,[col]); }; + const drawTopmarkConeSmall = (tx,ty,w,col) => { const h=w*0.7; drawCone(tx,ty-h,ty,w,[col]); }; + + const bs=opts.boyshp, cl=opts.catlam; + const isCan=bs===2||(!bs&&(cl===1||cl===3)); + const isCone=bs===1||(!bs&&(cl===2||cl===4)); + const isSph=bs===3, isSpar=bs===5, isBar=bs===6, isSuper=bs===7; + + if (isCan) { + drawCylinder(cx-bW/2,bTop,bW,bH,css.length?css:[c1]); + _st(ctx,cx,bTop-eRy,stTop); drawTopmarkCan(cx,stTop,bW*0.42,c1); + } else if (isCone) { + drawCone(cx,bTop,bBot,bW,css.length?css:[c1]); + _st(ctx,cx,bTop,stTop); drawTopmarkCone(cx,stTop,bW*0.44,c1); + } else if (isSph) { + const r=bH*0.44, bcy=bTop+r; drawSphere(cx,bcy,r,css.length?css:[c1]); + _st(ctx,cx,bcy-r,stTop); _tmSphere(ctx,cx,stTop-sz*0.09,sz*0.09,c1); + } else if (isSpar) { + const sw=bW*0.24, sparTop=bTop-bH*0.40; + drawCylinder(cx-sw/2,sparTop,sw,bH*1.40,css.length?css:[c1]); + if (cl===1||cl===3) drawTopmarkCan(cx,sparTop-2,sw*1.4,c1); + else if (cl===2||cl===4) drawTopmarkCone(cx,sparTop-2,sw*1.4,c1); + else drawTopmarkConeSmall(cx,sparTop-2,sw*1.4,c1); + } else { + // PILLAR (default) + const pw=bW*0.48; + drawCylinder(cx-pw/2,bTop+bH*0.4,pw,bH*0.6,css.length?css:[c1]); + drawCylinder(cx-pw*0.35,bTop,pw*0.7,bH*0.4,[_darken(c1,0.08)]); + _st(ctx,cx,bTop,stTop); + if (cl===1||cl===3) drawTopmarkCan(cx,stTop,bW*0.42,c1); + else if (cl===2||cl===4) drawTopmarkCone(cx,stTop,bW*0.44,c1); + else drawTopmarkConeSmall(cx,stTop,bW*0.50,c1); + } + _wl(ctx,cx,wlY,bW*0.68); + if (opts.hasLight) _drawLightFlare(ctx,cx,stTop+sz*0.02,css[0]||'#ffffff'); + return c; + } + + // ── Cardinal buoy canvas ───────────────────────────────────────────────── + function _encCardinalCanvas(quadrant, sz=40) { + const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2; + const BLK='#111111', YEL='#f9a825'; + const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.20; + const bW=sz*0.44, bH=bBot-bTop; + const bands = {N:[BLK,YEL],S:[YEL,BLK],E:[BLK,YEL,BLK],W:[YEL,BLK,YEL]}[quadrant]||[BLK,YEL]; + const bandH=bH/bands.length; + bands.forEach((col,i)=>{ ctx.fillStyle=col; ctx.fillRect(cx-bW/2,bTop+i*bandH,bW,bandH); }); + ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH); + _st(ctx,cx,bTop,sTop); + const cw=sz*0.36, ch=sz*0.14, t1=sTop-ch*0.2, t2=t1-ch-sz*0.01; + if (quadrant==='N') { _tmConeUp(ctx,cx,t2,cw,ch,BLK); _tmConeUp(ctx,cx,t1,cw,ch,BLK); } + else if (quadrant==='S') { _tmConeDown(ctx,cx,t2,cw,ch,BLK); _tmConeDown(ctx,cx,t1,cw,ch,BLK); } + else if (quadrant==='E') { _tmConeUp(ctx,cx,t2,cw,ch,BLK); _tmConeDown(ctx,cx,t1,cw,ch,BLK); } + else { _tmConeDown(ctx,cx,t2,cw,ch,BLK); _tmConeUp(ctx,cx,t1,cw,ch,BLK); } + _wl(ctx,cx,wlY,bW*0.65); + return c; + } + + // ── Isolated danger ────────────────────────────────────────────────────── + function _encIsdCanvas(sz=40) { + const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2; + const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28; + const bW=sz*0.44, bH=bBot-bTop; + ctx.fillStyle='#111'; ctx.fillRect(cx-bW/2,bTop,bW,bH); + ctx.fillStyle='#e53935'; ctx.fillRect(cx-bW/2,bTop+bH*0.35,bW,bH*0.30); + ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH); + _st(ctx,cx,bTop,sTop); + const sr=sz*0.07; _tmSphere(ctx,cx-sr*1.2,sTop-sr,sr,'#111'); _tmSphere(ctx,cx+sr*1.2,sTop-sr,sr,'#111'); + _wl(ctx,cx,wlY,bW*0.65); return c; + } + + // ── Safe water ─────────────────────────────────────────────────────────── + function _encSawCanvas(sz=40) { + const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2; + const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28; + const bW=sz*0.44, bH=bBot-bTop; + ctx.save(); ctx.beginPath(); ctx.rect(cx-bW/2,bTop,bW,bH); ctx.clip(); + for (let i=0;i<4;i++) { ctx.fillStyle=i%2===0?'#e53935':'#ffffff'; ctx.fillRect(cx-bW/2+i*bW/4,bTop,bW/4,bH); } + ctx.restore(); ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH); + _st(ctx,cx,bTop,sTop); _tmSphere(ctx,cx,sTop-sz*0.07,sz*0.07,'#e53935'); + _wl(ctx,cx,wlY,bW*0.65); return c; + } + + // ── Special buoy ───────────────────────────────────────────────────────── + function _encSppCanvas(sz=40) { + const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2; + const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28; + const bW=sz*0.44, bH=bBot-bTop; + ctx.fillStyle='#f9a825'; ctx.fillRect(cx-bW/2,bTop,bW,bH); + ctx.strokeStyle='#555'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH); + _st(ctx,cx,bTop,sTop); _tmX(ctx,cx,sTop-sz*0.09,sz*0.10,'#f9a825'); + _wl(ctx,cx,wlY,bW*0.65); return c; + } + + // ── Beacon ─────────────────────────────────────────────────────────────── + function _encBeaconCanvas(colours, catlam, region, sz=36) { + const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2; + let cc=(colours||[]).slice(); + if (!cc.length&&catlam) cc=_ialaLateralColours(catlam, region); + const css=cc.map(_s57css); const c1=css[0]||'#78909c'; + const ialaB=(region||'B')==='B'; + const groundY=sz*0.92, poleTopY=sz*0.04, poleW=sz*0.055; + const bodyW=sz*0.28, bodyTopY=sz*0.48, bodyBotY=groundY; + const tmW=sz*0.36, tmBotY=sz*0.27, tmTopY=sz*0.05; + + // Ground shadow + ctx.beginPath(); ctx.ellipse(cx,groundY,sz*0.12,sz*0.020,0,0,Math.PI*2); + ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fill(); + + // Pole + const poleGrd=ctx.createLinearGradient(cx-poleW,0,cx+poleW,0); + poleGrd.addColorStop(0,'#999'); poleGrd.addColorStop(0.4,'#ddd'); poleGrd.addColorStop(1,'#777'); + ctx.fillStyle=poleGrd; ctx.fillRect(cx-poleW/2,poleTopY,poleW,groundY-poleTopY); + + // Body + const bodyGrd=ctx.createLinearGradient(cx-bodyW/2,0,cx+bodyW/2,0); + bodyGrd.addColorStop(0,_lighten(c1,0.35)); bodyGrd.addColorStop(0.45,c1); bodyGrd.addColorStop(1,_darken(c1,0.28)); + ctx.fillStyle=bodyGrd; ctx.fillRect(cx-bodyW/2,bodyTopY,bodyW,bodyBotY-bodyTopY); + ctx.strokeStyle='rgba(0,0,0,0.65)'; ctx.lineWidth=0.9; + ctx.strokeRect(cx-bodyW/2,bodyTopY,bodyW,bodyBotY-bodyTopY); + + // Topmark: PORT = square, STBD = triangle + const isPort=catlam===1||catlam===3||(ialaB&&cc[0]===4)||(!ialaB&&cc[0]===3); + if (isPort) { + ctx.fillStyle=c1; ctx.fillRect(cx-tmW/2,tmTopY,tmW,tmBotY-tmTopY); + ctx.strokeStyle='#111'; ctx.lineWidth=0.8; ctx.strokeRect(cx-tmW/2,tmTopY,tmW,tmBotY-tmTopY); + } else { + ctx.beginPath(); ctx.moveTo(cx,tmTopY); ctx.lineTo(cx+tmW/2,tmBotY); ctx.lineTo(cx-tmW/2,tmBotY); ctx.closePath(); + ctx.fillStyle=c1; ctx.fill(); ctx.strokeStyle='#111'; ctx.lineWidth=0.8; ctx.stroke(); + } + return c; + } + + // ── Lighthouse / light symbol (S-52 style) ─────────────────────────────── + function _ialaLight(colourCode) { + const W=26, H=46; + const c=document.createElement('canvas'); c.width=W; c.height=H; + const ctx=c.getContext('2d'); const cx=W/2; + let col='#ffffff'; + if (colourCode===3) col='#dd1111'; + else if (colourCode===4) col='#00aa00'; + else if (colourCode===6) col='#ffcc00'; + const bW=14, bH=17, bTop=H*0.50, bBot=bTop+bH; + ctx.beginPath(); ctx.rect(cx-bW/2,bTop,bW,bH); + ctx.fillStyle=col; ctx.fill(); ctx.strokeStyle='#111'; ctx.lineWidth=1.2; ctx.stroke(); + // 5-point star + const starX=cx, starY=bTop+bH/2, Ro=4.5, Ri=1.85; + ctx.beginPath(); + for (let i=0;i<10;i++) { + const ang=(i*Math.PI/5)-Math.PI/2, rr=(i%2===0)?Ro:Ri; + const px=starX+Math.cos(ang)*rr, py=starY+Math.sin(ang)*rr; + i===0?ctx.moveTo(px,py):ctx.lineTo(px,py); + } + ctx.closePath(); ctx.fillStyle=(col==='#ffffff')?'#222':'#fff'; ctx.fill(); + // Magenta light flash + const PURP='#8800cc', tearAng=20*Math.PI/180, tearLen=18; + ctx.save(); ctx.translate(cx+bW/2-1,bTop+1); ctx.rotate(tearAng); + ctx.beginPath(); ctx.moveTo(0,0); + ctx.bezierCurveTo(-3.8,-5,-3.8,-11,0,-tearLen); + ctx.bezierCurveTo(3.8,-11,3.8,-5,0,0); + ctx.closePath(); ctx.fillStyle=PURP; ctx.globalAlpha=0.88; ctx.fill(); ctx.globalAlpha=1.0; + ctx.strokeStyle='rgba(90,0,140,0.5)'; ctx.lineWidth=0.5; ctx.stroke(); ctx.restore(); + // Anchor dot + ctx.beginPath(); ctx.arc(cx,H-2,2.2,0,Math.PI*2); ctx.fillStyle='#fff'; ctx.fill(); + ctx.beginPath(); ctx.arc(cx,H-2,1.0,0,Math.PI*2); ctx.fillStyle='#111'; ctx.fill(); + return c; + } + + // ── Light point (infers buoy shape from colour+region) ─────────────────── + function _encLightCanvas(colours, region, sz=32) { + const raw=colours[0]; + const ialaB=(region||'B')==='B'; + // White/magenta lights → simple lighthouse symbol + if (!raw || raw===1 || raw===12) return _ialaLight(raw||1); + // Coloured light → infer lateral buoy + const isPort = (ialaB&&raw===4)||(!ialaB&&raw===3); + const isStbd = (ialaB&&raw===3)||(!ialaB&&raw===4); + if (isPort||isStbd) { + const catlam = isPort?1:2; + return _encBuoyCanvas({ colours, boyshp: isPort?2:1, catlam, region, sz, hasLight:true }); + } + if (raw===6) return _encSppCanvas(sz); // yellow = special + return _ialaLight(raw); + } + + // ── SVG icon library — idénticos a AR ECDIS (anchor=bottom) ───────────── + var _svgSrc = (function () { + // Punto de posición S-52: pequeño, sutil, marca la posición exacta de la ayuda + var D = function (cx, cy) { + return ''; + }; + var icons = { + // ── Lateral IALA-B ────────────────────────────────────────────────── + 'boylat-port-b': '' + + '' + + '' + + '' + + D(10,27) + '', + 'boylat-stbd-b': '' + + '' + + D(10,25) + '', + // ── Lateral IALA-A ────────────────────────────────────────────────── + 'boylat-port': '' + + '' + + '' + + '' + + D(10,27) + '', + 'boylat-stbd': '' + + '' + + D(10,25) + '', + // ── Balizas IALA-B ────────────────────────────────────────────────── + 'bcnlat-port-b': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + D(11,45) + '', + 'bcnlat-stbd-b': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + D(11,45) + '', + // ── Balizas IALA-A ────────────────────────────────────────────────── + 'bcnlat-port': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + D(11,45) + '', + 'bcnlat-stbd': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + D(11,45) + '', + // ── Cardinales ────────────────────────────────────────────────────── + 'boycar-n': '' + + '' + + '' + + '' + + '' + + '' + + D(10,37) + '', + 'boycar-s': '' + + '' + + '' + + '' + + '' + + '' + + D(10,37) + '', + 'boycar-e': '' + + '' + + '' + + '' + + '' + + '' + + D(10,39) + '', + 'boycar-w': '' + + '' + + '' + + '' + + '' + + '' + + '' + + D(10,39) + '', + // ── Especiales ────────────────────────────────────────────────────── + 'boyisd': '' + + '' + + '' + + '' + + '' + + '' + + D(11,41) + '', + 'boysaw': '' + + '' + + '' + + '' + + '' + + '' + + '' + + D(9,35) + '', + 'boyspp': '' + + '' + + '' + + '' + + '' + + D(10,29) + '', + // ── Peligros S-52 ──────────────────────────────────────────────────── + // Wreck (WRECKS) — silueta de barco hundido, magenta S-52, contorno blanco para visibilidad + 'wrecks': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + // Roca sumergida (UWTROC) — asterisco de 8 puntas, naranja vivo S-52 + 'uwtroc': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + // Obstrucción (OBSTRN) — rombo con X, amarillo-naranja S-52 + 'obstrn': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + // ── Faro ──────────────────────────────────────────────────────────── + 'lighthouse': '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + }; + var out = {}; + Object.keys(icons).forEach(function (k) { + out[k] = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(icons[k]); + }); + return out; + })(); + + // ── ENC feature style — SIN etiquetas estáticas (solo en hover/click) ───── + // Las etiquetas se muestran únicamente en tooltip al hacer hover. + function encStyle(feature) { + var aidType = feature.get('aid_type') || ''; + var layer = (feature.get('layer') || '').toUpperCase(); + var region = feature.get('cell_region') || 'B'; + var isBcn = layer.startsWith('BCN'); + var ialaB = region !== 'A'; + var colours = feature.get('colours') || []; + + var iconKey = null; + + switch (aidType) { + case 'LATERAL_PORT': case 'LATERAL_PREF_STBD': case 'LATERAL_UNKNOWN': + iconKey = isBcn ? (ialaB ? 'bcnlat-port-b' : 'bcnlat-port') + : (ialaB ? 'boylat-port-b' : 'boylat-port'); + break; + case 'LATERAL_STBD': case 'LATERAL_PREF_PORT': + iconKey = isBcn ? (ialaB ? 'bcnlat-stbd-b' : 'bcnlat-stbd') + : (ialaB ? 'boylat-stbd-b' : 'boylat-stbd'); + break; + case 'CARDINAL_N': iconKey = 'boycar-n'; break; + case 'CARDINAL_S': iconKey = 'boycar-s'; break; + case 'CARDINAL_E': iconKey = 'boycar-e'; break; + case 'CARDINAL_W': iconKey = 'boycar-w'; break; + case 'CARDINAL_UNKNOWN': iconKey = 'boycar-n'; break; + case 'ISOLATED_DANGER': iconKey = 'boyisd'; break; + case 'SAFE_WATER': iconKey = 'boysaw'; break; + case 'SPECIAL': iconKey = 'boyspp'; break; + // Genéricos — fallback a símbolo por defecto del tipo + case 'BEACON_GENERIC': + iconKey = ialaB ? 'bcnlat-port-b' : 'bcnlat-port'; + break; + case 'BUOY_GENERIC': + iconKey = ialaB ? 'boylat-port-b' : 'boylat-port'; + break; + case 'LIGHT_POINT': { + // Luz S-52: sector de luz con extremo curvo (arco circular) inclinado ~40°-50° a la derecha. + // displacement=[0,28] sube 28px — aprox. al topmark de una boya/baliza estándar. + var raw = colours[0]; + var lCol = raw === 3 ? '#ee2200' : raw === 4 ? '#00cc00' + : raw === 6 ? '#ffcc00' : raw === 12 ? '#cc00aa' : '#ffffff'; + // Sector: fuente (2,20), ángulos ±~8° centrados en 42° de la vertical. + // Lado izq: 34°→ (2+15*sin34°, 20-15*cos34°) = (10.4, 7.6) + // Lado der: 50°→ (2+15*sin50°, 20-15*cos50°) = (13.5, 10.4) + // Extremo curvo: arco de r=15 centrado en (2,20) — clockwise (sweep=1) + var lSvg = '' + + '' + + '' + + '' + + '' + + ''; + return new ol.style.Style({ + image: new ol.style.Icon({ + src: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(lSvg), + // anchor en el punto-fuente (2,20) dentro del SVG de 20x22 + anchor: [2/20, 20/22], anchorXUnits: 'fraction', anchorYUnits: 'fraction', + scale: 1, + // Desplazar 28px hacia arriba — topmark de boya/baliza estándar ~30px alto + displacement: [0, 28], + }), + }); + } + case 'LANDMARK': case 'RACON': { + var nm = feature.get('name') || ''; + // Faro/lighthouse: nombre indica estructura de luz importante + var isLH = nm.indexOf('Faro') === 0 || nm.indexOf('Light') === 0 + || nm.indexOf('Lighthouse') === 0 || nm.indexOf('Breakwater Light') >= 0; + if (isLH) { + iconKey = 'lighthouse'; + } else { + // Otro hito: diamante marrón ECDIS + return new ol.style.Style({ + image: new ol.style.RegularShape({ + points: 4, radius: 6, angle: Math.PI / 4, + fill: new ol.style.Fill({ color: '#8b6914' }), + stroke: new ol.style.Stroke({ color: '#fffbe8', width: 1.2 }), + }), + }); + } + break; + } + case 'UNKNOWN': + default: { + // Círculo con color S-57 del primer COLOUR del objeto + var fc = colours[0] ? _s57css(colours[0]) : '#607d8b'; + return new ol.style.Style({ + image: new ol.style.Circle({ radius: 4, + fill: new ol.style.Fill({ color: fc }), + stroke: new ol.style.Stroke({ color: '#fff', width: 1.2 }), + }), + }); + } + } + + // Devolver icono SVG (o círculo gris como último fallback) + var src = iconKey ? _svgSrc[iconKey] : null; + if (!src) { + return new ol.style.Style({ + image: new ol.style.Circle({ radius: 5, + fill: new ol.style.Fill({ color: '#78909c' }), + stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }), + }), + }); + } + return new ol.style.Style({ + image: new ol.style.Icon({ + src: src, + anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction', + scale: 1, + }), + }); + } + + // ── Paleta de colores por modo (idéntica a AR ECDIS presentation.js) ──────── + // Ayudas a la navegación (boyas/balizas/luces) NUNCA se tocan — colores IALA. + // Solo se recoloran: DEPARE, LNDARE/LANDMASK, DEPCNT, SOUNDG, COALNE, FAIRWY. + var _chartMode = 'day'; + // Paleta S-52 idéntica a AR ECDIS (session_summary_20260502.md + ar_ecdis_s52_rendering.md). + // NUNCA tocar colores de ayudas IALA (boyas/balizas/luces) — solo DEPARE/LNDARE/DEPCNT/SOUNDG. + // coalne = color del trazo de costa (borde del polígono LNDARE) — oscuro para contraste. + var _S57_COLORS = { + night: { + depare: ['#232d3d','#1a2330','#141b28','#0f1520','#0a1018'], + lndare: '#221e18', coalne: '#a09878', + soundgText: '#888880', soundgHalo: 'rgba(8,8,6,0.95)', + depcnt: '#3a3d4a', osmOpacity: 0.18, bgColor: '#000408', + }, + dusk: { + depare: ['#2a4a6c','#213c5c','#1a304e','#152640','#101c30'], + lndare: '#302a1e', coalne: '#908a80', + soundgText: '#c0c0b0', soundgHalo: 'rgba(14,12,10,0.85)', + depcnt: '#506090', osmOpacity: 0.38, bgColor: '#040c18', + }, + 'day-std': { + depare: ['#c8e8f8','#90c8f0','#58a8e0','#3080c8','#1860b0'], + lndare: '#f0e8c0', coalne: '#2a2a2a', + soundgText: '#0a1e38', soundgHalo: 'rgba(220,236,252,0.70)', + depcnt: '#5070a0', osmOpacity: 0.82, bgColor: '#8ab8d8', + }, + day: { + // DAY_BRIGHT — paleta S-52 probada en AR ECDIS (ar_ecdis_s52_rendering.md) + depare: ['#c0e8f8','#a0d0f0','#80b8e8','#4078b8','#1a5a90'], + lndare: '#dfd5b0', coalne: '#1a1a1a', + soundgText: '#000000', soundgHalo: 'rgba(255,255,255,0.75)', + depcnt: '#4060a0', osmOpacity: 0.82, bgColor: '#b8d8f0', + }, + }; + + function _modeColors() { + return _S57_COLORS[_chartMode] || _S57_COLORS['day']; + } + + // ── Depth style — recolorea según modo, mantiene colores S-57 ───────────── + function depthStyle(feature) { + var layer = feature.get('layer'); + var C = _modeColors(); + if (layer === 'DEPARE') { + var dmax = feature.get('depth_max'); + if (dmax == null) dmax = 9999; + var fill; + if (dmax < 2) fill = C.depare[0]; + else if (dmax < 5) fill = C.depare[1]; + else if (dmax < 10) fill = C.depare[2]; + else if (dmax < 30) fill = C.depare[3]; + else fill = C.depare[4]; + return new ol.style.Style({ fill: new ol.style.Fill({ color: fill }) }); + } + if (layer === 'LANDMASK') { + return new ol.style.Style({ fill: new ol.style.Fill({ color: C.lndare }) }); + } + if (layer === 'DEPCNT') { + var depth = feature.get('depth'); + var isMajor = depth != null && depth % 10 === 0; + return new ol.style.Style({ + stroke: new ol.style.Stroke({ color: C.depcnt, width: isMajor ? 1.0 : 0.5, lineDash: isMajor ? null : [4,3] }), + }); + } + return null; // SOUNDG → soundgStyle() en soundgLayer dedicado + } + + // ── Land layer style — diferencia por capa S-57 y modo de presentación ────── + // + // PROBLEMA RAÍZ: los polígonos LNDARE del S-57 en puertos (ej. Miami Port) incluyen + // muelles y diques que flanquean canales de navegación. Rellenarlos sólidamente TAPA + // el canal de cruceros aunque éste esté correctamente marcado como DEPARE. + // + // SOLUCIÓN: en modos día (OSM al 82-90%) NO rellenamos LNDARE — el OSM ya muestra + // la tierra. Solo ponemos el trazo de costa (COALNE). El canal queda visible porque + // DEPARE (zIndex 2, azul) no está tapado por LNDARE. + // + // En noche/dusk (OSM al 12-38%) SÍ usamos relleno S-52 — sin él la tierra sería + // indistinguible del océano oscuro. + function _landStyle(feature) { + var l = feature.get('layer'); + var C = _modeColors(); + var isDayMode = (_chartMode === 'day' || _chartMode === 'day-std'); + var adv = (_detailLevel === 'advanced'); + + // COALNE — trazo de línea de costa + // CRÍTICO: en S-57 COALNE a veces viene como Polygon (área de celda con la costa como borde). + // Hacer stroke a un Polygon grande crea líneas diagonales donde el anillo exterior "cierra" + // desde el último vértice al primero cruzando el agua. + // Solo hacemos stroke si la geometría es LineString (COALNE real), no Polygon. + if (l === 'COALNE') { + if (adv && !_advLayers.coalne) return null; + var gtype = feature.getGeometry().getType(); + if (gtype === 'Polygon' || gtype === 'MultiPolygon') return null; + return new ol.style.Style({ + stroke: new ol.style.Stroke({ color: C.coalne, width: isDayMode ? 1.2 : 1.0 }), + }); + } + + // BUISGL — edificios aislados (modo oscuro) + if (l === 'BUISGL') { + if (isDayMode) return null; + return new ol.style.Style({ + stroke: new ol.style.Stroke({ color: 'rgba(100,80,50,0.55)', width: 0.6 }), + }); + } + + // BUAARE — zona urbana: borde tenue sin fill + if (l === 'BUAARE') { + if (adv && !_advLayers.buaare) return null; + if (isDayMode) return null; // en día OSM tiene mejor detalle + return new ol.style.Style({ + stroke: new ol.style.Stroke({ color: C.coalne, width: 0.7, lineDash: [4, 4] }), + }); + } + + // LNDARE — polígono de tierra + // NUNCA hacer stroke: el anillo exterior del polígono LNDARE puede tener un borde de "cierre" + // que va del último al primer vértice cruzando el agua — diagonal inevitable en S-57. + // El borde visual costa/agua surge naturalmente del FILL que limita con el color de agua. + if (l === 'LNDARE') { + if (isDayMode) return null; // día: LANDMASK + OSM ya muestran la tierra + // Modos oscuros: relleno sólido S-52 SIN stroke para evitar diagonales + if (adv && !_advLayers.lndare) return null; + return new ol.style.Style({ + fill: new ol.style.Fill({ color: C.lndare }), + }); + } + + return null; + } + + // ── Routing helpers — misma depthSource, capas OL distintas ────────────── + function depAreaStyle(feature) { + var l = feature.get('layer'); + // LANDMASK: pinta tierra con C.lndare para tapar DEPARE que pueda sobresalir en zonas de tierra. + // Se renderiza ANTES que DEPARE en el source (ver _parse_depths en chart_manager.py), + // por lo que DEPARE (canal) se pinta encima → el canal queda visible. + if (l === 'LANDMASK') { + if (_detailLevel === 'advanced' && !_advLayers.landmask) return null; + return depthStyle(feature); + } + if (l !== 'DEPARE') return null; + // En avanzado, respetar flag _advLayers.depare + if (_detailLevel === 'advanced' && !_advLayers.depare) return null; + return depthStyle(feature); + } + function depLineStyle(feature) { + var l = feature.get('layer'); + if (l !== 'DEPCNT') return null; // SOUNDG ya va por soundgLayer dedicado + // BÁSICO: nunca mostrar veriles — solo áreas DEPARE (manejadas por depAreaStyle) + if (_detailLevel === 'basic') return null; + // AVANZADO: respetar flag individual + if (_detailLevel === 'advanced' && !_advLayers.depcnt) return null; + return depthStyle(feature); + } + + // ── Hazard style — SVG S-52 (wrecks/uwtroc/obstrn idénticos a AR ECDIS) ── + function hazardStyle(feature) { + var cat = feature.get('category'); + var iconKey = cat === 'wreck' ? 'wrecks' : cat === 'rock' ? 'uwtroc' : 'obstrn'; + var src = _svgSrc[iconKey]; + if (!src) { + var col = cat === 'wreck' ? '#e53935' : cat === 'rock' ? '#f59e0b' : '#f97316'; + return new ol.style.Style({ + image: new ol.style.Circle({ radius: 6, + fill: new ol.style.Fill({ color: col }), + stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }), + }), + }); + } + return new ol.style.Style({ + image: new ol.style.Icon({ + src: src, scale: 1, + anchor: [0.5, 0.5], anchorXUnits: 'fraction', anchorYUnits: 'fraction', + }), + }); + } + + // ── Zone style ──────────────────────────────────────────────────────────── + function zoneStyle(feature) { + const cat = feature.get('category'); + // Opacidades elevadas para visibilidad en modo día sobre OSM + const fills = { + restricted: 'rgba(220,40,40,0.22)', + caution: 'rgba(255,150,0,0.22)', + anchorage: 'rgba(0,100,210,0.22)', + fairway: 'rgba(0,170,90,0.18)', + traffic_lane: 'rgba(80,80,240,0.18)', + precautionary:'rgba(200,120,0,0.18)', + dumpground: 'rgba(120,80,40,0.18)', + pipeline_area:'rgba(160,100,180,0.18)', + }; + const strokes = { + restricted: 'rgba(200,30,30,0.85)', + caution: 'rgba(220,130,0,0.85)', + anchorage: 'rgba(0,80,190,0.85)', + fairway: 'rgba(0,150,70,0.80)', + traffic_lane: 'rgba(60,60,210,0.80)', + precautionary:'rgba(180,100,0,0.80)', + dumpground: 'rgba(100,60,20,0.80)', + pipeline_area:'rgba(140,80,160,0.80)', + }; + const fill = fills[cat] || 'rgba(120,120,120,0.14)'; + const stroke = strokes[cat] || 'rgba(100,100,100,0.65)'; + return new ol.style.Style({ + fill: new ol.style.Fill({ color: fill }), + stroke: new ol.style.Stroke({ color: stroke, width: 1.2, lineDash: [7,4] }), + }); + } + + // ── Chart layers — stack S-52 (abajo=mayor área, arriba=menor) ─────────── + // zIndex 0 bgLayer ocean bg (map.js) + // zIndex 1 osmLayer OSM opcional (map.js) + // zIndex 2 depAreaLayer DEPARE áreas de profundidad (polígonos grandes, bajo tierra) + // zIndex 4 landLayer LNDARE tierra firme + // zIndex 5 depLineLayer DEPCNT veriles (líneas) — SOBRE tierra + // zIndex 6 zoneLayer zonas náuticas (semitransparente) + // zIndex 7 hazardLayer naufragios, rocas (puntos) + // zIndex 8 encLayer boyas, balizas, luces (puntos — tope ENC) + // zIndex 9 soundgLayer SOUNDG sondas — capa dedicada, sobre todo lo demás S-57 + // zIndex 20+ track, rutas, barco propio (map.js) + + // Fuentes + const depthSource = new ol.source.Vector(); // DEPARE + DEPCNT + LANDMASK + const soundgSource = new ol.source.Vector(); // SOUNDG — fuente dedicada (igual que ECDIS) + const landSource = new ol.source.Vector(); // LNDARE + const zoneSource = new ol.source.Vector(); // zonas + const encSource = new ol.source.Vector(); // balizamiento ENC + const hazardSource = new ol.source.Vector(); // peligros + + // Estilo dedicado para sondas — idéntico a ECDIS: text-allow-overlap + text-ignore-placement + // Ancla invisible — OL 9 canvas renderer no renderiza texto en Points que solo + // tienen `text` style sin ningún `image`/`fill`/`stroke`. El Circle radio 1 + // transparente es suficiente para que OL registre el feature como "dibujable" + // y luego renderice el texto. El usuario no ve el círculo (1px transparente). + var _soundgAnchor = new ol.style.Circle({ + radius: 1, + fill: new ol.style.Fill({ color: 'rgba(0,0,0,0)' }), + }); + + function soundgStyle(feature) { + if (_detailLevel !== 'advanced' || !_advLayers.soundg) return null; + var d = feature.get('depth'); + if (d == null) return null; + var C = _modeColors(); + var txt = d < 10 ? d.toFixed(1) : Math.round(d).toString(); + return new ol.style.Style({ + image: _soundgAnchor, // ancla invisible — necesaria para que OL renderice el texto + text: new ol.style.Text({ + text: txt, + font: 'bold 11px Arial, sans-serif', + textAlign: 'center', + textBaseline: 'middle', + fill: new ol.style.Fill({ color: C.soundgText }), + overflow: true, // no recortar etiquetas en el borde del tile + }), + }); + } + + // Capas + const depAreaLayer = new ol.layer.Vector({ // polígonos de profundidad (debajo de tierra) + source: depthSource, zIndex: 2, style: depAreaStyle, + }); + const landLayer = new ol.layer.Vector({ // tierra — zIndex 4 enmascara veriles bajo tierra + source: landSource, zIndex: 4, + style: _landStyle, + }); + const depLineLayer = new ol.layer.Vector({ // veriles DEPCNT — solo líneas, sin sondas + source: depthSource, zIndex: 5, style: depLineStyle, + }); + const soundgLayer = new ol.layer.Vector({ // sondas — capa dedicada como en ECDIS + source: soundgSource, zIndex: 9, + style: soundgStyle, + declutter: false, // mostrar todas las sondas sin filtrar solapadas + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + const zoneLayer = new ol.layer.Vector({ // zonas náuticas — encima de veriles + source: zoneSource, zIndex: 6, style: zoneStyle, + }); + const hazardLayer = new ol.layer.Vector({ // naufragios / rocas + source: hazardSource, zIndex: 7, style: hazardStyle, maxResolution: 600, declutter: true, + }); + const encLayer = new ol.layer.Vector({ // boyas / balizas / luces + source: encSource, zIndex: 8, style: encStyle, + }); + + // Attach layers — idempotent: skip if already added + var _layersAttached = false; + var _moveendTimer = null; + function _attachLayers() { + if (_layersAttached) return; + var olMap = GPSMap.getOLMap(); + if (!olMap) return; + // Orden de addLayer no importa — OL usa zIndex para pintar. + [depAreaLayer, landLayer, zoneLayer, depLineLayer, hazardLayer, encLayer, soundgLayer].forEach(function(l) { + olMap.addLayer(l); + }); + // Cargar celdas nuevas al mover/hacer zoom (debounced 800ms) + olMap.on('moveend', function() { + clearTimeout(_moveendTimer); + _moveendTimer = setTimeout(function() { + if (window.py) loadAll(); + }, 800); + }); + + // ── Hover: tooltip sobre ayudas ENC ───────────────────────────────────── + // Solo capas de puntos (encLayer, hazardLayer). + // Las zonas (polígonos grandes) NO participan en hover — bloquearían los puntos. + var _ptLayers = [encLayer, hazardLayer]; + olMap.on('pointermove', function(evt) { + if (evt.dragging) { _hideTooltip(); return; } + var feat = olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) { + if (_ptLayers.indexOf(l) >= 0) return f; + }, { hitTolerance: 14 }); + + if (feat) { + // evt.pixel es relativo al viewport del mapa — correcto para posicionar tooltip + var px = evt.pixel[0]; + var py = evt.pixel[1]; + _showTooltip(feat, px, py); + if (!window.GPSMap || GPSMap.getDrawMode() === 'none') { + olMap.getTargetElement().style.cursor = 'pointer'; + } + } else { + _hideTooltip(); + if (!window.GPSMap || GPSMap.getDrawMode() === 'none') { + olMap.getTargetElement().style.cursor = ''; + } + } + }); + + // ── Click: mostrar info en panel derecho ───────────────────────────────── + olMap.on('click', function(evt) { + if (window.GPSMap && GPSMap.getDrawMode() !== 'none') return; + var feat = olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) { + if (_ptLayers.indexOf(l) >= 0) return f; + }, { hitTolerance: 16 }); + if (feat) _showInfoPanel(feat); + }); + + _layersAttached = true; + } + + // ── GeoJSON loaders ─────────────────────────────────────────────────────── + // _loadGeoJSON: limpia la fuente y carga desde cero (usado en reloadAll) + function _loadGeoJSON(source, geojson) { + source.clear(); + return _addGeoJSON(source, geojson); + } + + // _addGeoJSON: añade features SIN limpiar (carga progresiva de celdas) + function _addGeoJSON(source, geojson) { + if (!geojson || !geojson.features || !geojson.features.length) return 0; + try { + var fmt = new ol.format.GeoJSON(); + var features = fmt.readFeatures(geojson, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857', + }); + source.addFeatures(features); + return features.length; + } catch(e) { + console.warn('[ChartLayer] _addGeoJSON error:', e); + return 0; + } + } + + // _addDepthsGeoJSON: split depths — SOUNDG → soundgSource, el resto → depthSource + // Necesario porque OL no renderiza texto-solo Points mezclados con Line/Polygon en misma fuente. + function _addDepthsGeoJSON(fc) { + if (!fc || !fc.features) return [0, 0]; + var soundg = [], others = []; + fc.features.forEach(function(f) { + if (f.properties && f.properties.layer === 'SOUNDG') soundg.push(f); + else others.push(f); + }); + console.log('[ChartLayer] depths split → depthSource:', others.length, + '| soundgSource:', soundg.length, + '| soundg flag:', _advLayers.soundg); + var nD = _addGeoJSON(depthSource, { type: 'FeatureCollection', features: others }); + var nS = _addGeoJSON(soundgSource, { type: 'FeatureCollection', features: soundg }); + return [nD || 0, nS || 0]; + } + + // ── Icon cache — usar toDataURL para máxima compat. con WebEngine ───────── + var _iconCache = {}; + function _cachedIcon(key, drawFn) { + if (_iconCache[key]) return _iconCache[key]; + try { + var cv = drawFn(); + _iconCache[key] = cv ? cv.toDataURL('image/png') : null; + } catch(e) { + _iconCache[key] = null; + } + return _iconCache[key]; + } + + // Wrapper que usa toDataURL en lugar de img: canvas + var _origCi = _ci; + function _ciUrl(key, fn) { + if (_iconCache[key] !== undefined) return _iconCache[key]; + try { + var cv = fn(); + _iconCache[key] = cv ? cv.toDataURL('image/png') : null; + } catch(e) { + _iconCache[key] = null; + } + return _iconCache[key]; + } + + // ── Lectura per-celda via bridge Python ────────────────────────────────── + // Igual que ECDIS: un slot por celda/tipo → cada respuesta cabe en QWebChannel. + // Se evita fetch() HTTP que puede fallar según el esquema de carga (file://). + async function _fetchCellFile(cellId, dataType, addRegion, region) { + try { + var raw = await _py('get_cell_data', cellId, dataType); + var fc = JSON.parse(raw); + var feats = fc.features || []; + if (addRegion) { + feats.forEach(function(f) { + if (!f.properties) f.properties = {}; + f.properties.cell_region = region; + }); + } + return feats; + } catch(e) { + console.warn('[ChartLayer] get_cell_data failed:', cellId, dataType, e.message || e); + return []; + } + } + + // Agrega features de todas las celdas para un tipo de dato dado + async function _loadAllCells(cells, dataType, addRegion) { + // Carga secuencial para no saturar el QWebChannel con muchos slots en vuelo + var all = []; + for (var i = 0; i < cells.length; i++) { + var c = cells[i]; + var feats = await _fetchCellFile(c.id, dataType, addRegion, c.region || 'B'); + all = all.concat(feats); + } + return { type: 'FeatureCollection', features: all }; + } + + // ── Filtro de celdas por viewport ───────────────────────────────────────── + // Devuelve las celdas cuyo bbox se solapa con el extent del mapa actual. + // extent = [minLon, minLat, maxLon, maxLat] + function _cellsInView(cells, extent) { + var minLon = extent[0], minLat = extent[1], maxLon = extent[2], maxLat = extent[3]; + return cells.filter(function(c) { + if (!c.bbox || c.bbox.length < 4) return false; + var w = c.bbox[0], s = c.bbox[1], e = c.bbox[2], n = c.bbox[3]; + // Filtro AABB: ¿hay solapamiento? + return w <= maxLon && e >= minLon && s <= maxLat && n >= minLat; + }); + } + + // Convierte el extent del mapa OL (EPSG:3857) a [minLon,minLat,maxLon,maxLat] + function _mapExtentLonLat() { + try { + var olMap = GPSMap.getOLMap(); + if (!olMap) return null; + var sz = olMap.getSize(); + if (!sz || !sz[0] || !sz[1]) return null; // mapa sin renderizar aún + var ext3857 = olMap.getView().calculateExtent(sz); + if (!ext3857 || isNaN(ext3857[0])) return null; + var sw = ol.proj.toLonLat([ext3857[0], ext3857[1]]); + var ne = ol.proj.toLonLat([ext3857[2], ext3857[3]]); + if (isNaN(sw[0]) || isNaN(ne[0])) return null; + return [sw[0], sw[1], ne[0], ne[1]]; + } catch(e) { + console.warn('[ChartLayer] _mapExtentLonLat error:', e.message || e); + return null; + } + } + + // ── Nivel de detalle ───────────────────────────────────────────────────── + // 'basic' → aids + land + DEPARE fills (sin líneas de veril) + // 'medium' → + veriles (DEPCNT) + hazards (4 tipos) + // 'advanced' → capas seleccionables via modal + var _detailLevel = 'basic'; + + // Flags por capa para nivel 'advanced' (controlados por modal de capas) + var _advLayers = { + // ── Profundidades ────────────────────────────────────────────────────── + depare: true, // áreas de profundidad (fills azules) + depcnt: true, // veriles / isobatas (líneas) + soundg: false, // sondas (texto de profundidad) + hazards: true, // naufragios, rocas, obstrucciones + zones: false, // zonas restringidas, fondeo, tráfico + // ── Tierra / costa ──────────────────────────────────────────────────── + coalne: true, // COALNE — trazo de línea de costa + lndare: false, // LNDARE — bordes del polígono tierra (genera líneas diagonales) + landmask: true, // LANDMASK — relleno beige de tierra S-57 + buaare: false, // BUAARE — límites de zonas urbanas + // ── Mapa base ───────────────────────────────────────────────────────── + osm: true, // OSM tiles (mapa base) + }; + + function _dataTypes() { + if (_detailLevel === 'advanced') { + // Construir lista según flags individuales + var types = ['features', 'land']; + if (_advLayers.depare || _advLayers.depcnt || _advLayers.soundg) types.push('depths'); + if (_advLayers.hazards) types.push('hazards'); + if (_advLayers.zones) types.push('zones'); + return types; + } + if (_detailLevel === 'medium') + return ['features', 'land', 'depths', 'hazards']; + // basic: aids + tierra + DEPARE fills (sin líneas — depLineStyle las filtra) + return ['features', 'land', 'depths']; + } + + function setLayerVisibility(opts) { + Object.keys(opts).forEach(function(k) { + if (k in _advLayers) _advLayers[k] = !!opts[k]; + }); + // Refrescar estilos inmediatamente + depthSource.changed(); + soundgSource.changed(); + landSource.changed(); + hazardLayer.setVisible(_advLayers.hazards); + zoneLayer.setVisible(_advLayers.zones); + // OSM — toggle visibilidad del tile layer + var olMap = GPSMap.getOLMap(); + if (olMap) { + olMap.getLayers().forEach(function(lyr) { + if (lyr && lyr.getSource && lyr.getSource() instanceof ol.source.OSM) { + lyr.setVisible(_advLayers.osm); + } + }); + } + // Recargar solo si se activó un tipo que no estaba cargado + _loading = false; + _loadedCellIds.clear(); + loadAll(); + } + + function getAdvLayers() { return Object.assign({}, _advLayers); } + + // ── Public API ──────────────────────────────────────────────────────────── + var _loadedCellIds = new Set(); // evita recargar celdas ya cargadas + var _loading = false; // guard anti-reentrada: QWebChannel es secuencial + + async function loadAll() { + _attachLayers(); + if (!window.py) { console.warn('[ChartLayer] bridge not ready'); return; } + if (_loading) { console.log('[ChartLayer] carga en curso, ignorando llamada duplicada'); return; } + _loading = true; + + // Obtener lista de celdas del bridge (JSON pequeño — sin problema de tamaño) + var allCells; + try { + allCells = JSON.parse(await _py('get_chart_cells')); + } catch(e) { + console.warn('[ChartLayer] get_chart_cells failed:', e.message || e); + _loading = false; return; + } + if (!allCells || !allCells.length) { + console.warn('[ChartLayer] no cells installed'); + _loading = false; return; + } + + var cellsWithBbox = allCells.filter(function(c){ return c.bbox; }); + + // Filtro de viewport — siempre usar extent si está disponible + var extent = _mapExtentLonLat(); + var cells; + if (extent) { + cells = _cellsInView(cellsWithBbox, extent); + console.log('[ChartLayer] nivel:', _detailLevel, + '| extent:', extent.map(function(v){return v.toFixed(2);}).join(','), + '→', cells.length, '/', cellsWithBbox.length, 'celdas en view'); + } else { + // Fallback: sin extent válido (primer frame), cargar todas + cells = cellsWithBbox; + console.log('[ChartLayer] nivel:', _detailLevel, + '| sin extent — todas las celdas con bbox:', cells.length); + } + + // Filtrar celdas ya cargadas con el nivel actual + // (al cambiar nivel se hace reloadAll que limpia _loadedCellIds) + cells = cells.filter(function(c){ return !_loadedCellIds.has(c.id); }); + if (!cells.length) { + console.log('[ChartLayer] sin celdas nuevas que cargar'); + _loading = false; return; + } + + var types = _dataTypes(); + console.log('[ChartLayer] cargando', cells.length, 'celdas × [' + types.join(', ') + ']:', + cells.slice(0,6).map(function(c){ return c.id; }).join(', '), + cells.length > 6 ? '...' : ''); + + // Carga secuencial por celda/tipo — cada mensaje QWebChannel < ~700 KB + try { + var nFeat=0, nLand=0, nDep=0, nSoundg=0, nHaz=0, nZone=0; + + if (types.indexOf('features') >= 0) { + var feat = await _loadAllCells(cells, 'features', true); + nFeat = _addGeoJSON(encSource, feat) || 0; + } + if (types.indexOf('land') >= 0) { + var land = await _loadAllCells(cells, 'land', false); + nLand = _addGeoJSON(landSource, land) || 0; + } + if (types.indexOf('hazards') >= 0) { + var haz = await _loadAllCells(cells, 'hazards', false); + nHaz = _addGeoJSON(hazardSource, haz) || 0; + } + if (types.indexOf('depths') >= 0) { + var dep = await _loadAllCells(cells, 'depths', false); + var depCounts = _addDepthsGeoJSON(dep); // split: SOUNDG → soundgSource + nDep = depCounts[0]; nSoundg = depCounts[1]; + } + if (types.indexOf('zones') >= 0) { + var zone = await _loadAllCells(cells, 'zones', false); + nZone = _addGeoJSON(zoneSource, zone) || 0; + } + + cells.forEach(function(c){ _loadedCellIds.add(c.id); }); + console.log('[ChartLayer] LISTO — aids:', nFeat, 'land:', nLand, + 'depths:', nDep, 'soundg:', nSoundg, 'hazards:', nHaz, 'zones:', nZone); + } finally { + _loading = false; + } + } + + async function reloadAll() { + // Limpiar fuentes y set de celdas cargadas, luego recargar el viewport + [landSource, zoneSource, depthSource, soundgSource, encSource, hazardSource].forEach(function(s){ s.clear(); }); + _loadedCellIds.clear(); + _loading = false; // resetear guard para que loadAll pueda arrancar + return loadAll(); + } + + function setDetailLevel(lvl) { + if (lvl !== 'basic' && lvl !== 'medium' && lvl !== 'advanced') return; + _detailLevel = lvl; + ['basic', 'medium', 'advanced'].forEach(function(l) { + var btn = document.getElementById('enc-lvl-' + l); + if (btn) btn.classList.toggle('active', l === lvl); + }); + reloadAll(); + } + + // ── setChartMode — recolorea capas S-57 por modo (sin tocar ayudas IALA) ── + // Mismo principio que AR ECDIS presentation.js: + // · DEPARE/LNDARE/DEPCNT/SOUNDG → fill color cambia por modo + // · BOYLAT/BOYCAR/LIGHTS/etc. → colores IALA, NUNCA cambian + // · OSM tiles → opacidad reducida en modos oscuros + function setChartMode(mode) { + var validModes = ['night', 'dusk', 'day-std', 'day']; + if (validModes.indexOf(mode) < 0) return; + _chartMode = mode; + // Recolorar capas de área/profundidad — los SVG de ayudas no se tocan + depthSource.changed(); + soundgSource.changed(); // sondas en capa dedicada también se recoloran + // landLayer usa _landStyle (función) que lee _chartMode dinámicamente — solo changed() + landSource.changed(); + // Ajustar opacidad OSM — oscuro en modos noche/dusk, normal en día + var C = _modeColors(); + var olMap = GPSMap.getOLMap(); + if (olMap) { + olMap.getLayers().forEach(function(lyr) { + if (lyr && lyr.getSource && lyr.getSource() instanceof ol.source.OSM) { + lyr.setOpacity(C.osmOpacity); + } + }); + } + console.log('[ChartLayer] modo presentación:', mode, '| OSM opacity:', C.osmOpacity); + } + + // ── Hover tooltip & click info panel ───────────────────────────────────── + // Tooltip: div flotante sobre el mapa, aparece solo en hover. + // Panel: sección en panel derecho, aparece al hacer click en una ayuda. + + var _ttEl = null; // tooltip DOM element + + function _ensureTT() { + if (_ttEl) return _ttEl; + var wrap = document.getElementById('map-wrap'); + if (!wrap) return null; + _ttEl = document.createElement('div'); + _ttEl.className = 'enc-tooltip'; + _ttEl.style.display = 'none'; + wrap.appendChild(_ttEl); + return _ttEl; + } + + // Texto HTML escapado + function _esc(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>'); + } + + // Formato grados-minutos "11°01.234'N" + function _fmtDeg(deg, pPos, pNeg) { + var a = Math.abs(deg), d = Math.floor(a), m = (a - d) * 60; + return d + '°' + m.toFixed(3) + '’' + (deg < 0 ? pNeg : pPos); + } + + // Extraer info estructurada de un feature OL + function _featInfo(f) { + var aid = f.get('aid_type') || ''; + var layer = (f.get('layer') || '').toUpperCase(); + var name = f.get('name') || ''; + var light = f.get('light_desc') || ''; + var cat = f.get('category') || ''; + var region = f.get('cell_region') || 'B'; + var colours= f.get('colours') || []; + var depth = f.get('depth'); + var dmax = f.get('depth_max'); + + var TYPE_LABELS = { + 'LATERAL_PORT': 'Lateral Port', 'LATERAL_STBD': 'Lateral Starboard', + 'LATERAL_PREF_PORT': 'Pref. Ch. Port', 'LATERAL_PREF_STBD': 'Pref. Ch. Stbd', + 'LATERAL_UNKNOWN': 'Lateral Unknown', 'CARDINAL_N': 'Cardinal N', + 'CARDINAL_S': 'Cardinal S', 'CARDINAL_E': 'Cardinal E', + 'CARDINAL_W': 'Cardinal W', 'CARDINAL_UNKNOWN': 'Cardinal', + 'ISOLATED_DANGER': 'Isolated Danger', 'SAFE_WATER': 'Safe Water', + 'SPECIAL': 'Special Mark', 'LIGHT_POINT': 'Light', + 'LANDMARK': 'Landmark', 'RACON': 'Racon', + 'BEACON_GENERIC': 'Beacon', 'BUOY_GENERIC': 'Buoy', + 'UNKNOWN': 'Unknown', + }; + var CAT = { wreck:'Wreck', rock:'Underwater Rock', obstruction:'Obstruction' }; + var CNAMES = {1:'White',2:'Black',3:'Red',4:'Green',5:'Blue',6:'Yellow', + 7:'Grey',8:'Brown',9:'Amber',10:'Violet',11:'Orange',12:'Magenta'}; + + var typeLabel = TYPE_LABELS[aid] || CAT[cat] || layer || 'Feature'; + var objClass = layer.startsWith('BCN') ? 'Beacon' : (layer.indexOf('BOY') >= 0 ? 'Buoy' : ''); + if (objClass && typeLabel.indexOf(objClass) < 0) typeLabel = typeLabel + ' ' + objClass; + var regionStr = region === 'A' ? 'IALA-A' : 'IALA-B'; + var colorStr = colours.map(function(c) { return CNAMES[c] || ''; }).filter(Boolean).join('/'); + + // Coordenadas del objeto + var coords = null; + try { + var geom = f.getGeometry(); + if (geom) { + var pt = geom.getType() === 'Point' + ? geom.getCoordinates() + : ol.extent.getCenter(geom.getExtent()); + if (pt) coords = ol.proj.toLonLat(pt); + } + } catch(e) {} + + return { aid, layer, name, light, cat, region, regionStr, colours, colorStr, + depth, dmax, coords, typeLabel }; + } + + // Posicionar tooltip cerca del cursor (evita salirse del mapa) + function _posTT(px, py) { + if (!_ttEl) return; + var wrap = document.getElementById('map-wrap'); + var W = wrap ? wrap.offsetWidth : 800; + var H = wrap ? wrap.offsetHeight : 600; + var ew = _ttEl.offsetWidth || 180; + var eh = _ttEl.offsetHeight || 52; + var left = px + 16, top = py - Math.round(eh / 2); + if (left + ew > W - 8) left = px - ew - 12; + if (top < 4) top = 4; + if (top + eh > H - 4) top = H - eh - 4; + _ttEl.style.left = left + 'px'; + _ttEl.style.top = top + 'px'; + } + + // Capas que NO deben mostrar tooltip (demasiado ruido) + var _noTooltipLayers = { DEPARE:1, LANDMASK:1, LNDARE:1, DEPCNT:1, SOUNDG:1 }; + + function _showTooltip(f, px, py) { + var el = _ensureTT(); if (!el) return; + var info = _featInfo(f); + if (_noTooltipLayers[info.layer]) { _hideTooltip(); return; } + + var html = '
' + _esc(info.typeLabel) + '
'; + if (info.name) html += '
' + _esc(info.name) + '
'; + if (info.light) html += '
💡 ' + _esc(info.light) + '
'; + else if (info.colorStr) html += '
' + _esc(info.colorStr) + '
'; + el.innerHTML = html; + el.style.display = 'block'; + // Posicionar después de renderizar (para leer offsetWidth real) + setTimeout(function() { _posTT(px, py); }, 0); + } + + function _hideTooltip() { + if (_ttEl) _ttEl.style.display = 'none'; + } + + // ── Info panel (panel derecho) ──────────────────────────────────────────── + function _showInfoPanel(f) { + var el = document.getElementById('rp-feat-info'); + if (!el) return; + var info = _featInfo(f); + + // Color del badge según tipo de ayuda + var bc = '#607d8b'; + if (info.aid.indexOf('PORT') >= 0) bc = '#009900'; + if (info.aid.indexOf('STBD') >= 0) bc = '#cc1111'; + if (info.aid.indexOf('CARDINAL') >= 0) bc = '#f0c000'; + if (info.aid === 'ISOLATED_DANGER') bc = '#cc1111'; + if (info.aid === 'SAFE_WATER') bc = '#ee2222'; + if (info.aid === 'LIGHT_POINT') bc = '#cc00cc'; + if (info.aid === 'LANDMARK') bc = '#8b6914'; + if (info.cat === 'wreck') bc = '#e53935'; + if (info.cat === 'rock') bc = '#f59e0b'; + + function row(lbl, val, cls) { + if (!val && val !== 0) return ''; + return '
' + + '' + lbl + '' + + '' + _esc(String(val)) + '
'; + } + + var rows = ''; + if (info.name) rows += row('Name', info.name); + if (info.light) rows += row('Light', info.light, 'fi-light'); + if (info.colorStr) rows += row('Colors', info.colorStr); + if (info.aid && (info.aid.indexOf('LATERAL') >= 0 || info.aid.indexOf('CARDINAL') >= 0)) + rows += row('Region', info.regionStr); + if (info.cat) rows += row('Cat.', info.cat); + if (info.dmax != null) rows += row('Depth max', info.dmax.toFixed(1) + ' m'); + if (info.depth != null) rows += row('Depth', info.depth.toFixed(1) + ' m'); + if (info.coords) { + rows += row('Lat', _fmtDeg(info.coords[1], 'N', 'S')); + rows += row('Lon', _fmtDeg(info.coords[0], 'E', 'W')); + } + + el.innerHTML = + '
' + + '' + + '' + _esc(info.typeLabel) + '' + + '' + + '
' + + (rows ? '
' + rows + '
' + : '
No hay datos adicionales
'); + el.style.display = 'block'; + } + + function hideFeatureInfo() { + var el = document.getElementById('rp-feat-info'); + if (el) el.style.display = 'none'; + } + + return { loadAll, reloadAll, setDetailLevel, setLayerVisibility, getAdvLayers, + setChartMode, hideFeatureInfo, + encSource, depthSource, hazardSource, zoneSource, landSource }; +})(); diff --git a/frontend/js/map.js b/frontend/js/map.js new file mode 100644 index 0000000..46de7de --- /dev/null +++ b/frontend/js/map.js @@ -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 + ? ('' + d.emoji + '') + : ('' + + '' + + ''); + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + '' + + '' + + '' + + '' + + '' + + '' + + svgInner + + '' + ); + } + + // Icono SVG ECDIS: círculo con cruz y punto central (IEC 61174 — planned position) + function _wptSvg(active) { + const col = active ? '#ffcc00' : '#00d8f0'; + const fill = active ? '0.22' : '0.10'; + const sw = active ? '2.2' : '1.8'; + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + '' + + '' + + '' + + '' + + '' + + '' + ); + } + + // ── Waypoints ────────────────────────────────────────────────────────────── + let _activeNavWpt = null; + + function renderWaypoints(wpts) { + wptSource.clear(); + (wpts || []).forEach(w => { + const f = new ol.Feature({ + geometry: new ol.geom.Point(ol.proj.fromLonLat([w.lon, w.lat])), + wpt: w, + }); + f.setId('wpt_' + w.id); + const isActive = _activeNavWpt && _activeNavWpt.id === w.id; + var isLocked = !!w.locked; + var col = isActive ? '#ffcc00' : '#00d8f0'; + var styles = [new ol.style.Style({ + image: new ol.style.Icon({ + src: _wptSvg(isActive), + anchor: [0.5, 0.5], anchorXUnits: 'fraction', anchorYUnits: 'fraction', + scale: 1, + }), + text: new ol.style.Text({ + text: w.name, + offsetX: 14, offsetY: -8, + font: 'bold 10px "JetBrains Mono", monospace', + textAlign: 'left', + fill: new ol.style.Fill({ color: col }), + stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.92)', width: 3 }), + }), + })]; + if (isLocked) { + styles.push(new ol.style.Style({ + text: new ol.style.Text({ + text: '🔒', offsetX: 10, offsetY: 10, + font: '10px sans-serif', textAlign: 'left', + }), + })); + } + f.setStyle(styles); + wptSource.addFeature(f); + }); + } + + function renderRoutes(routes, wptMap) { + routeSource.clear(); + (routes || []).forEach(r => { + const wpts = (r.wpt_ids || []).map(id => wptMap[id]).filter(Boolean); + if (wpts.length < 2) return; + const coords = wpts.map(w => ol.proj.fromLonLat([w.lon, w.lat])); + + // Línea de ruta — cyan sólida ECDIS (IEC 61174 planned route) + const lineFeat = new ol.Feature({ geometry: new ol.geom.LineString(coords), route: r }); + lineFeat.setStyle(new ol.style.Style({ + stroke: new ol.style.Stroke({ color: 'rgba(0,210,240,0.88)', width: 1.8 }), + })); + routeSource.addFeature(lineFeat); + + // Etiqueta de cada tramo: rumbo verdadero y distancia en NM + for (let i = 0; i < wpts.length - 1; i++) { + const w1 = wpts[i], w2 = wpts[i+1]; + const c1 = ol.proj.fromLonLat([w1.lon, w1.lat]); + const c2 = ol.proj.fromLonLat([w2.lon, w2.lat]); + const mid = [(c1[0]+c2[0])/2, (c1[1]+c2[1])/2]; + const brg = _brg(w1.lat, w1.lon, w2.lat, w2.lon); + const dst = _nm(w1.lat, w1.lon, w2.lat, w2.lon); + const lbl = String(Math.round(brg)).padStart(3,'0') + '°T ' + dst.toFixed(2) + ' NM'; + + const midFeat = new ol.Feature({ geometry: new ol.geom.Point(mid) }); + midFeat.setStyle(new ol.style.Style({ + text: new ol.style.Text({ + text: lbl, + font: '9px "JetBrains Mono", monospace', + offsetY: -10, + fill: new ol.style.Fill({ color: '#00e8ff' }), + stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.90)', width: 3 }), + backgroundFill: new ol.style.Fill({ color: 'rgba(6,14,28,0.72)' }), + padding: [2, 5, 2, 5], + }), + image: new ol.style.RegularShape({ // pequeño rombo marcador de tramo + points: 4, radius: 3, angle: Math.PI/4, + fill: new ol.style.Fill({ color: 'rgba(0,210,240,0.80)' }), + }), + })); + routeSource.addFeature(midFeat); + } + }); + } + + // ── Marcas POI ──────────────────────────────────────────────────────────── + function renderMarks(marks) { + marksSource.clear(); + (marks || []).forEach(function(m) { + var f = new ol.Feature({ + geometry: new ol.geom.Point(ol.proj.fromLonLat([m.lon, m.lat])), + mark: m, + }); + f.setId('mark_' + m.id); + var isLocked = !!m.locked; + var markStyles = [new ol.style.Style({ + image: new ol.style.Icon({ + src: _markSvg(m.mark_type, false), + anchor: [0.5, 1.0], + anchorXUnits: 'fraction', anchorYUnits: 'fraction', + scale: 1, + }), + text: new ol.style.Text({ + text: m.name, + offsetY: -44, offsetX: 0, + font: 'bold 10px "Inter", sans-serif', + textAlign: 'center', + fill: new ol.style.Fill({ color: (_MARK_DEFS[m.mark_type] || _MARK_DEFS.poi).col }), + stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.92)', width: 3 }), + }), + })]; + if (isLocked) { + markStyles.push(new ol.style.Style({ + text: new ol.style.Text({ + text: '🔒', offsetX: 12, offsetY: -26, + font: '11px sans-serif', textAlign: 'left', + }), + })); + } + f.setStyle(markStyles); + marksSource.addFeature(f); + }); + } + + // ── Translate interaction — WPTs y marcas arrastrables ─────────────────── + // Se inicializa tarde (en _initTranslate) porque necesita el mapa ya creado. + function _initTranslate() { + var tr = new ol.interaction.Translate({ + layers: [wptLayer, marksLayer], + hitTolerance: 8, + filter: function(feature) { + var w = feature.get('wpt'); + var m = feature.get('mark'); + if (w && w.locked) return false; + if (m && m.locked) return false; + return true; + }, + }); + tr.on('translateend', function(evt) { + evt.features.forEach(function(f) { + var newLonLat = ol.proj.toLonLat(f.getGeometry().getCoordinates()); + var wpt = f.get('wpt'); + var mark = f.get('mark'); + if (wpt) { + wpt.lon = newLonLat[0]; wpt.lat = newLonLat[1]; + window.onWptDrag && window.onWptDrag(wpt); + } + if (mark) { + mark.lon = newLonLat[0]; mark.lat = newLonLat[1]; + window.onMarkDrag && window.onMarkDrag(mark); + } + }); + }); + map.addInteraction(tr); + } + _initTranslate(); + + // ── COG vector ──────────────────────────────────────────────────────────── + let _cogFeature = null; + function _updateCogVector() { + if (_lat == null || _sog < 0.3) { + if (_cogFeature) { ownSource.removeFeature(_cogFeature); _cogFeature = null; } + return; + } + const distM = _sog * 0.514444 * 360; // 6 min ahead + const h = _cog * Math.PI / 180; + const mLat = 111320, mLon = 111320 * Math.cos(_lat * Math.PI / 180); + const tipLon = _lon + (distM * Math.sin(h)) / mLon; + const tipLat = _lat + (distM * Math.cos(h)) / mLat; + const from = ol.proj.fromLonLat([_lon, _lat]); + const to = ol.proj.fromLonLat([tipLon, tipLat]); + if (!_cogFeature) { + _cogFeature = new ol.Feature({ geometry: new ol.geom.LineString([from, to]) }); + _cogFeature.setStyle(new ol.style.Style({ + stroke: new ol.style.Stroke({ color: 'rgba(204,0,255,0.80)', width: 2.0, lineDash: [7,4] }), + })); + ownSource.addFeature(_cogFeature); + } else { + _cogFeature.getGeometry().setCoordinates([from, to]); + } + } + + // ── Map click ───────────────────────────────────────────────────────────── + map.on('click', evt => { + const [lon, lat] = ol.proj.toLonLat(evt.coordinate); + + if (_drawMode === 'wpt') { + window.onMapClickWpt && window.onMapClickWpt(lat, lon); + return; + } + + if (_drawMode === 'mark') { + window.onMapClickMark && window.onMapClickMark(lat, lon); + return; + } + + if (_drawMode === 'route') { + _routeDraftCoords.push([lon, lat]); + _updateRouteDraft(); + window.onMapClickRoute && window.onMapClickRoute(lat, lon, _routeDraftCoords.length); + return; + } + + // Normal mode: click en WPT o MARCA para info + const f = map.forEachFeatureAtPixel(evt.pixel, f => f, { hitTolerance: 10 }); + if (f && f.get('wpt')) { window.onWptMapClick && window.onWptMapClick(f.get('wpt')); } + if (f && f.get('mark')) { window.onMarkMapClick && window.onMarkMapClick(f.get('mark')); } + }); + + map.on('dblclick', evt => { + if (_drawMode === 'route' && _routeDraftCoords.length >= 2) { + window.onMapDblClickRoute && window.onMapDblClickRoute(_routeDraftCoords.slice()); + _clearRouteDraft(); + evt.preventDefault(); + } + }); + + // ── Draft route rendering ───────────────────────────────────────────────── + function _updateRouteDraft() { + routeDraftSource.clear(); + if (_routeDraftCoords.length < 1) return; + // Line + if (_routeDraftCoords.length >= 2) { + routeDraftSource.addFeature(new ol.Feature({ + geometry: new ol.geom.LineString(_routeDraftCoords.map(c => ol.proj.fromLonLat(c))), + })); + } + // Dots at each waypoint + _routeDraftCoords.forEach(c => { + routeDraftSource.addFeature(new ol.Feature({ + geometry: new ol.geom.Point(ol.proj.fromLonLat(c)), + })); + }); + } + + function _clearRouteDraft() { + _routeDraftCoords = []; + routeDraftSource.clear(); + } + + // ── Pointer coordinates + cursor feedback ──────────────────────────────── + map.on('pointermove', evt => { + const [lon, lat] = ol.proj.toLonLat(evt.coordinate); + const el = document.getElementById('map-coords'); + if (el) el.textContent = `LAT ${lat.toFixed(5)}° LON ${lon.toFixed(5)}°`; + // Crosshair cursor in draw modes + map.getTargetElement().style.cursor = _drawMode !== 'none' ? 'crosshair' : ''; + }); + + // ── Public API ──────────────────────────────────────────────────────────── + function update(lat, lon, cog, sog) { + const moved = (_lat !== lat || _lon !== lon); + _lat = lat; _lon = lon; _cog = cog || 0; _sog = sog || 0; + _updateOwnShip(); + _updateCogVector(); + if (moved) _appendTrack(lon, lat); + } + + function centerOnGPS() { + // Push-button: centra una sola vez, sin auto-follow + if (_lat == null) return; + map.getView().animate({ center: ol.proj.fromLonLat([_lon, _lat]), duration: 300 }); + } + + function setOrientation(mode) { + _orientation = mode; + document.getElementById('btn-north').classList.toggle('active', mode === 'N'); + document.getElementById('btn-course').classList.toggle('active', mode === 'C'); + if (mode === 'N') map.getView().setRotation(0); + _updateOwnShip(); + } + + function toggleTrack(vis) { + _trackVis = vis !== undefined ? vis : !_trackVis; + trackLayer.setVisible(_trackVis); + const btn = document.getElementById('btn-track'); + if (btn) btn.classList.toggle('active', _trackVis); + } + + function clearTrackMap() { + _trackCoords = []; + trackSource.clear(); + } + + function setActiveNav(wpt) { + _activeNavWpt = wpt; + } + + function getCenter() { + return ol.proj.toLonLat(map.getView().getCenter()); + } + + function getOLMap() { return map; } + + function zoomIn() { + var v = map.getView(); + v.animate({ zoom: Math.min((v.getZoom() || 12) + 1, 20), duration: 220 }); + } + function zoomOut() { + var v = map.getView(); + v.animate({ zoom: Math.max((v.getZoom() || 12) - 1, 2), duration: 220 }); + } + + function setDrawMode(mode) { + _drawMode = mode; // 'none' | 'wpt' | 'route' + if (mode === 'none') _clearRouteDraft(); + map.getTargetElement().style.cursor = mode !== 'none' ? 'crosshair' : ''; + } + + function cancelDraw() { + _drawMode = 'none'; + _clearRouteDraft(); + map.getTargetElement().style.cursor = ''; + } + + function getDrawMode() { return _drawMode; } + + // Control de opacidad del layer OSM — permite modo night/dusk sin filtrar las ayudas IALA. + // Las ayudas (encLayer) están en otra capa OL y no se ven afectadas por este ajuste. + function setOsmOpacity(v) { + osmLayer.setOpacity(v == null ? 0.82 : Math.max(0, Math.min(1, v))); + } + + // Fondo del canvas #map — cambia el color de océano base según modo + function setMapBackground(color) { + var el = document.getElementById('map'); + if (el) el.style.background = color || '#a8c8e8'; + } + + return { + update, centerOnGPS, setOrientation, toggleTrack, + clearTrackMap, renderWaypoints, renderRoutes, renderMarks, + setActiveNav, getCenter, loadTrack, getOLMap, + setDrawMode, cancelDraw, getDrawMode, zoomIn, zoomOut, + setOsmOpacity, setMapBackground, + marksSource, + }; +})(); + +// Bind toolbar buttons +function centerOnGPS() { GPSMap.centerOnGPS(); } +function setOrientation(m){ GPSMap.setOrientation(m); } +function toggleTrack() { GPSMap.toggleTrack(); } +function mapZoomIn() { GPSMap.zoomIn(); } +function mapZoomOut() { GPSMap.zoomOut(); } +function clearTrack() { + if (!window.py) return; + if (!confirm('Clear the GPS track log?')) return; + window.py.clear_track(); + GPSMap.clearTrackMap(); +} +function addWptAtCenter() { + const [lon, lat] = GPSMap.getCenter(); + window.openWptModal && window.openWptModal({ lat, lon }); +} diff --git a/frontend/js/skyplot.js b/frontend/js/skyplot.js new file mode 100644 index 0000000..ecb6a89 --- /dev/null +++ b/frontend/js/skyplot.js @@ -0,0 +1,311 @@ +'use strict'; +/* Sky plot (azimuth/elevation) + SNR signal bars. + Mode-aware: lee data-mode del 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 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 }; +})(); diff --git a/main.py b/main.py new file mode 100644 index 0000000..1e83b3f --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f73e90 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..c718dc8 --- /dev/null +++ b/start.bat @@ -0,0 +1,8 @@ +@echo off +cd /d "%~dp0" +echo. +echo GPS Navigator ^| Standalone PyQt5 App +echo ======================================== +echo. +python main.py +pause