'use strict';

let MAP_CENTER_LAT = 11.0041;   // Barranquilla — default for admin/superadmin
let MAP_CENTER_LON = -74.8070;
let MAP_ZOOM = 11;

// IALA region: 'A' = Europe/Africa/Asia (red=port), 'B' = Americas/Japan (green=port)
// Can be changed from settings; default B for Colombian Caribbean
window.IALA_REGION = localStorage.getItem('ams_iala') || 'B';

// ── Capas ──────────────────────────────────────────────────────────────────
// Capa base OSM — cobertura mundial completa, todos los niveles de zoom
const osmLayer = new ol.layer.Tile({
  source: new ol.source.OSM({ crossOrigin: 'anonymous' }),
  opacity: 1.0,
});

// Placeholder — se mantiene la variable para no romper applyNightMode ni el array de capas
const oceanRefLayer = new ol.layer.Tile({
  source: new ol.source.OSM({ crossOrigin: 'anonymous' }),
  opacity: 0,
  visible: false,
});

const seaMapLayer = new ol.layer.Tile({
  source: new ol.source.XYZ({
    url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
    crossOrigin: 'anonymous',
  }),
  opacity: 1.0,
});

const vesselsSource = new ol.source.Vector();
const aidsSource    = new ol.source.Vector();
// Ghost layer: AIS-reported actual position when it differs from nominal.
// Rendered translucent so the nominal marker stays the "well-marked" one.
const aisGhostSource = new ol.source.Vector();
window.vesselsSource = vesselsSource;

const vesselsLayer = new ol.layer.Vector({ source: vesselsSource, zIndex: 10, declutter: true });
const aidsLayer    = new ol.layer.Vector({ source: aidsSource,    zIndex: 20, declutter: true });
const aisGhostLayer = new ol.layer.Vector({
  source: aisGhostSource,
  zIndex: 21,            // sits just above the nominal aid so it's visible
  declutter: false,
});

// ── S-57 ENC vector layer ─────────────────────────────────────────────────
const encSource = new ol.source.Vector();
const encLayer  = new ol.layer.Vector({
  source: encSource,
  zIndex: 19,                 // S-57 aids on top of everything except live AIS aids
  style: encStyle,
  // 12000 m/px ≈ zoom 6 — layer visible from here; encStyle scales symbols
  // down smoothly so they shrink instead of blinking out as user zooms away.
  // Per-cell band ranges inside _cellShouldRender still refine visibility.
  maxResolution: 12000,
  // declutter: false — aids are physical objects that don't overlap in practice.
  // With declutter:true, ol.style.Icon gets hidden by collision but ol.style.Circle
  // (the position dot) does NOT participate in declutter, leaving ghost dots.
  declutter: false,
});

// ── Bathymetry — split into 2 layers so SOUNDG (numbers) renders ON TOP of
// terrain & buildings (ECDIS S-52 layer order). DEPARE/DEPCNT/LANDMASK stay
// at low zIndex underneath the land mass.
const depthSource = new ol.source.Vector();
const depthLayer  = new ol.layer.Vector({
  source: depthSource,
  zIndex: 2,            // above OSM/seamap, below land/aids
  visible: true,
  style: depthStyle,
  declutter: true,
  // Painters' order: DEPARE deepest-first so shallower areas paint on top,
  // then LANDMASK (covers any residual depth bleed), then DEPCNT contours.
  renderOrder: (a, b) => {
    const RANK = { DEPARE: 0, LANDMASK: 1, DEPCNT: 2 };
    const ra = RANK[a.get('layer')] ?? 4;
    const rb = RANK[b.get('layer')] ?? 4;
    if (ra !== rb) return ra - rb;
    if (a.get('layer') === 'DEPARE') {
      return (b.get('depth_max') || 0) - (a.get('depth_max') || 0);
    }
    return 0;
  },
});

// SOUNDG depth numbers — rendered above terrain & buildings but BELOW the
// aids/hazards layers, so buoys and wrecks always stay visible over the
// soundings (operational priority: navigation marks > depth numbers).
const soundSource = new ol.source.Vector();
const soundLayer  = new ol.layer.Vector({
  source: soundSource,
  zIndex: 4.5,
  visible: true,
  style: soundStyle,
  declutter: true,
});

// ── Zones layer (RESARE, CTNARE, ACHARE, TSSLPT, FAIRWY…) ────────────────────
// zIndex 3: drawn ON the water (above depths) but BELOW land polygons
const zoneSource = new ol.source.Vector();
const zoneLayer  = new ol.layer.Vector({
  source: zoneSource,
  zIndex: 3,
  style: zoneStyle,
  visible: true,
});

// ── Land / terrain layer (LNDARE polygons, BUAARE, COALNE, structures) ───────
// zIndex 4: above zones so land covers channel overlays where there's terrain
const landSource = new ol.source.Vector();
const landLayer  = new ol.layer.Vector({
  source: landSource,
  zIndex: 4,
  style: landStyle,
  visible: true,
  renderOrder: (a, b) => {
    const RANK = { LNDARE: 0, BUAARE: 1, COALNE: 2 };
    const ra = RANK[a.get('layer')] ?? 3;  // points (BRIDGE, HRBFAC, BUISGL…) on top
    const rb = RANK[b.get('layer')] ?? 3;
    return ra - rb;
  },
});

// ── Hazards layer (WRECKS, OBSTRN, UWTROC) ───────────────────────────────────
const hazardSource = new ol.source.Vector();
const hazardLayer  = new ol.layer.Vector({
  source: hazardSource,
  zIndex: 6,
  style: hazardStyle,
  maxResolution: 150,
  declutter: true,
  visible: true,
});

// ── Canvas symbol cache ───────────────────────────────────────────────────
const _iconCache = {};
window._iconCache = _iconCache;   // expose for settings live-update
function _cachedIcon(key, drawFn) {
  if (!_iconCache[key]) _iconCache[key] = drawFn();
  return _iconCache[key];
}

// ── Soft-blur wrapper for aid canvases ────────────────────────────────────
// Applies a gentle gaussian blur to soften jagged canvas edges.
// Uses a WeakMap so each source canvas is blurred exactly ONCE — the result
// is reused on every subsequent style call (same cost as the cached original).
const _blurCache = new WeakMap();
function _softCanvas(src, blurPx = 0.7) {
  if (_blurCache.has(src)) return _blurCache.get(src);
  const dst = document.createElement('canvas');
  dst.width  = src.width;
  dst.height = src.height;
  const ctx = dst.getContext('2d');
  ctx.filter = `blur(${blurPx}px)`;
  ctx.drawImage(src, 0, 0);
  _blurCache.set(src, dst);
  return dst;
}

// ── Ship canvas ───────────────────────────────────────────────────────────
function _drawShip(color, sz = 32) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  const h = sz / 2;
  ctx.save();
  ctx.translate(h, h);
  // hull
  ctx.beginPath();
  ctx.moveTo(0, -h + 3);          // bow
  ctx.bezierCurveTo(6, -4, 6, 4, 5, h - 3);   // starboard
  ctx.lineTo(-5, h - 3);          // stern
  ctx.bezierCurveTo(-6, 4, -6, -4, 0, -h + 3); // port
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.85)';
  ctx.lineWidth = 1.5;
  ctx.stroke();
  // bridge rect
  ctx.fillStyle = 'rgba(255,255,255,0.35)';
  ctx.fillRect(-3, -2, 6, 6);
  // bow arrow
  ctx.beginPath();
  ctx.moveTo(0, -h + 2);
  ctx.lineTo(3, -h + 8);
  ctx.lineTo(-3, -h + 8);
  ctx.closePath();
  ctx.fillStyle = 'rgba(255,255,255,0.6)';
  ctx.fill();
  ctx.restore();
  return c;
}

// ── Aid canvas helpers ────────────────────────────────────────────────────
function _drawCircleDiamond(fill, stroke, sz = 22) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  const h = sz / 2;
  // circle body
  ctx.beginPath();
  ctx.arc(h, h, h - 2, 0, Math.PI * 2);
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.strokeStyle = stroke;
  ctx.lineWidth = 2;
  ctx.stroke();
  // inner diamond
  ctx.beginPath();
  ctx.moveTo(h, 3); ctx.lineTo(sz - 3, h);
  ctx.lineTo(h, sz - 3); ctx.lineTo(3, h);
  ctx.closePath();
  ctx.strokeStyle = 'rgba(255,255,255,0.7)';
  ctx.lineWidth = 1.5;
  ctx.stroke();
  return c;
}

function _drawLighthouse(fill, sz = 26) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  const h = sz / 2;
  // 8-point star
  ctx.beginPath();
  for (let i = 0; i < 8; i++) {
    const a = (i * Math.PI) / 4;
    const r = i % 2 === 0 ? h - 1 : (h - 1) * 0.45;
    const x = h + Math.cos(a - Math.PI / 2) * r;
    const y = h + Math.sin(a - Math.PI / 2) * r;
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.closePath();
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.8)';
  ctx.lineWidth = 1.5;
  ctx.stroke();
  // center dot
  ctx.beginPath();
  ctx.arc(h, h, 3, 0, Math.PI * 2);
  ctx.fillStyle = '#fff';
  ctx.fill();
  return c;
}

function _drawDiamond(fill, stroke, sz = 22) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  const h = sz / 2;
  ctx.beginPath();
  ctx.moveTo(h, 1); ctx.lineTo(sz - 1, h);
  ctx.lineTo(h, sz - 1); ctx.lineTo(1, h);
  ctx.closePath();
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.strokeStyle = stroke;
  ctx.lineWidth = 2;
  ctx.stroke();
  return c;
}

function _drawTriangle(fill, stroke, sz = 22, inverted = false) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  ctx.beginPath();
  if (!inverted) {
    ctx.moveTo(sz / 2, 1);
    ctx.lineTo(sz - 1, sz - 1);
    ctx.lineTo(1, sz - 1);
  } else {
    ctx.moveTo(1, 1);
    ctx.lineTo(sz - 1, 1);
    ctx.lineTo(sz / 2, sz - 1);
  }
  ctx.closePath();
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.strokeStyle = stroke;
  ctx.lineWidth = 2;
  ctx.stroke();
  return c;
}

function _drawCardinalBuoy(quadrant, sz = 24) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  const h = sz / 2;
  // Black/yellow banding by quadrant (IALA)
  const topBlack = ['N','NE','E','NW'].includes(quadrant);
  ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2);
  ctx.fillStyle = topBlack ? '#000' : '#f5c518'; ctx.fill();
  ctx.beginPath(); ctx.arc(h, h, h - 2, Math.PI, 0);
  ctx.fillStyle = topBlack ? '#f5c518' : '#000'; ctx.fill();
  ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2);
  ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
  // Arrows indicating safe water side
  ctx.fillStyle = '#fff';
  ctx.font = `bold ${sz - 8}px sans-serif`;
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  const arrows = {N:'↑',S:'↓',E:'→',W:'←',NE:'↗',NW:'↖',SE:'↘',SW:'↙'};
  ctx.fillText(arrows[quadrant] || '◆', h, h);
  return c;
}

function _drawSpecialMark(fill, sz = 20) {
  const c = document.createElement('canvas');
  c.width = c.height = sz;
  const ctx = c.getContext('2d');
  const h = sz / 2;
  // X shape (special mark — yellow cross)
  ctx.beginPath();
  ctx.arc(h, h, h - 2, 0, Math.PI * 2);
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.strokeStyle = 'rgba(0,0,0,0.6)';
  ctx.lineWidth = 1.5;
  ctx.stroke();
  ctx.strokeStyle = 'rgba(0,0,0,0.7)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(5, 5); ctx.lineTo(sz - 5, sz - 5);
  ctx.moveTo(sz - 5, 5); ctx.lineTo(5, sz - 5);
  ctx.stroke();
  return c;
}

// ── 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(code) { return S57_CSS[code] || '#78909c'; }

/* ═══════════════════════════════════════════════════════════════════════════
   IALA Nautical Symbols — exact silhouettes per IALA Maritime Buoyage System
   Reference: IALA Publication No.1, IHO INT-1, IALA Region A & B charts
   Layout per symbol (top→bottom): topmark → staff → body → waterline
   ═══════════════════════════════════════════════════════════════════════════ */

function _mkC(sz) {
  const c = document.createElement('canvas'); c.width = sz; c.height = sz; return c;
}
function _wl(ctx, cx, y, hw) {  // waterline
  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) {  // staff
  ctx.strokeStyle = '#222'; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(cx, y1); ctx.lineTo(cx, y2); ctx.stroke();
}

// Topmark: cone pointing UP  ▲
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();
}
// Topmark: cone pointing DOWN  ▼
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();
}
// Topmark: sphere  ●
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();
}
// Topmark: X cross
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();
}

// ── True-3D colour helpers ────────────────────────────────────────────────
function _h2r(hex) {               // hex or rgb() → [r,g,b]
  if (typeof hex === 'string' && hex.startsWith('rgb')) {
    const m = hex.match(/\d+/g);
    return m ? [+m[0], +m[1], +m[2]] : [0, 0, 0];
  }
  hex = (hex || '').replace('#','');
  if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
  return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)];
}
function _lighten3D(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 _darken3D(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))})`;
}
// Cylindrical side gradient: bright left → main → dark right
function _cylGrad(ctx, x, w, yMid, col) {
  const g = ctx.createLinearGradient(x, yMid, x + w, yMid);
  g.addColorStop(0,    _lighten3D(col, 0.58));
  g.addColorStop(0.22, _lighten3D(col, 0.18));
  g.addColorStop(0.55, col);
  g.addColorStop(0.82, _darken3D(col, 0.32));
  g.addColorStop(1,    _darken3D(col, 0.55));
  return g;
}
// Conical gradient: same as cyl but stronger contrast
function _coneGrad(ctx, x, w, yMid, col) {
  const g = ctx.createLinearGradient(x, yMid, x + w, yMid);
  g.addColorStop(0,    _lighten3D(col, 0.65));
  g.addColorStop(0.20, _lighten3D(col, 0.20));
  g.addColorStop(0.55, col);
  g.addColorStop(1,    _darken3D(col, 0.60));
  return g;
}
// Banded fill for multi-colour shapes, each band individually gradienterd
function _fillBands3D(ctx, pathFn, bx, by, bw, bh, cssColours, gradFn) {
  ctx.save(); pathFn(ctx); ctx.clip();
  if (!cssColours.length) {
    ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh);
  } else {
    const bandH = bh / cssColours.length;
    cssColours.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();
}
// Kept for backward compat with cardinal / isd / saw helpers
function _fillBands(ctx, pathFn, bbox, cssColours) {
  const [bx, by, bw, bh] = bbox;
  ctx.save(); pathFn(ctx); ctx.clip();
  if (!cssColours.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); }
  else {
    const bandH = bh / cssColours.length;
    cssColours.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(bx, by + i * bandH, bw, bandH + 0.6); });
  }
  ctx.restore();
}
// Kept for backward compat
function _apply3DShade(ctx, pathFn, bbox) {
  const [bx, by, bw, bh] = bbox;
  ctx.save(); pathFn(ctx); ctx.clip();
  const g = ctx.createLinearGradient(bx, by+bh/2, bx+bw, by+bh/2);
  g.addColorStop(0,'rgba(255,255,255,0.38)'); g.addColorStop(0.3,'rgba(255,255,255,0.06)');
  g.addColorStop(0.65,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.32)');
  ctx.fillStyle = g; ctx.fillRect(bx, by, bw, bh); ctx.restore();
}

// ── IALA fallback colours when S-57 COLOUR is missing ─────────────────────
// Returns S-57 colour CODES (not CSS) so they go through normal banding.
function _ialaLateralColours(catlam, region) {
  const ialaB = (region || 'B') === 'B';
  const G = 4, R = 3;
  const port = ialaB ? G : R;     // port-hand colour
  const stbd = ialaB ? R : G;     // starboard-hand colour
  if (catlam === 1) return [port];
  if (catlam === 2) return [stbd];
  if (catlam === 3) return [port, stbd, port];   // pref-channel-to-stbd: port w/ stbd band
  if (catlam === 4) return [stbd, port, stbd];   // pref-channel-to-port: stbd w/ port band
  return [];
}

// Fill a closed path with N horizontal bands (used for multi-colour buoys).
function _fillBands(ctx, pathFn, bbox, cssColours) {
  const [bx, by, bw, bh] = bbox;
  ctx.save();
  pathFn(ctx); ctx.clip();
  if (cssColours.length === 0) {
    ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh);
  } else {
    const bandH = bh / cssColours.length;
    cssColours.forEach((col, i) => {
      ctx.fillStyle = col;
      ctx.fillRect(bx, by + i * bandH, bw, bandH + 0.6);
    });
  }
  ctx.restore();
}

// ══════════════════════════════════════════════════════════════════════════
//  TRUE-3D BUOY SYMBOLS  — geometry is built in 3 dimensions:
//   • Cylinders use side-rect + top-ellipse + bottom-ellipse
//   • Cones use face-triangle + base-ellipse + highlight streak
//   • Spheres use radial gradient with upper-left highlight
//   • Barrels use two bezier curves + elliptical ends
//   • Topmarks are correct per IALA: PORT = square cube, STBD = cone up
// opts: {colours, boyshp, catlam, region, sz?, hasLight}
//   BOYSHP: 1=conical  2=can  3=sphere  4=pillar  5=spar  6=barrel  7=super-buoy
// ══════════════════════════════════════════════════════════════════════════
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 ialaB = (opts.region || 'B') === 'B';

  // ── Glow helper: apply outer glow on shape outlines via shadowBlur ──────
  // Call before strokeRect/stroke; reset after.  col = CSS colour of the glow.
  const _glow = (col, blur = 4) => {
    ctx.shadowBlur  = blur;
    ctx.shadowColor = col;
  };
  const _noGlow = () => { ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; };

  // ── Layout ──────────────────────────────────────────────────────────────
  const wlY   = sz * 0.92;           // waterline
  const bBot  = sz * 0.88;           // body bottom (waterline region)
  const bTop  = sz * 0.52;           // body top (pushed down for topmark room)
  // stTop must leave room for topmark above it (topmark height ≈ bW * 0.40)
  const _tmRoom = sz * 0.52 * 0.40;  // topmark height reservation
  const stTop = opts.hasLight
    ? Math.max(sz * 0.20, _tmRoom + sz * 0.03)
    : Math.max(sz * 0.24, _tmRoom + sz * 0.03);
  const bW    = sz * 0.52;           // body width
  const bH    = bBot - bTop;         // body height
  const eRy   = bH * 0.095;         // ellipse y-radius for cylinder caps

  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;
  const isSpar  = bs === 5;
  const isBar   = bs === 6;
  const isSuper = bs === 7;

  // ── Shared draw helpers ─────────────────────────────────────────────────
  // Glow colour: lighten primary colour, used for outline glow
  const glowCol = c1 ? _lighten3D(c1, 0.55) : 'rgba(255,255,255,0.7)';
  const outln = (lw = 0.9) => { _noGlow(); ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = lw; ctx.stroke(); };
  const outlnGlow = (lw = 1.2) => {
    _glow(glowCol, 5);
    ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = lw; ctx.stroke();
    _noGlow();
  };

  // Draw a cylinder: sides + bottom ellipse + top ellipse
  const drawCylinder = (x, topY, w, h, colList) => {
    const ery = h * 0.095;
    // Side face (banded)
    _fillBands3D(ctx, cc => { cc.beginPath(); cc.rect(x, topY, w, h); }, x, topY, w, h, colList, _cylGrad);
    // Glow outline on side
    _glow(glowCol, 5);
    ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 1.0; ctx.strokeRect(x, topY, w, h);
    _noGlow();
    ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 0.6; ctx.strokeRect(x, topY, w, h);
    // Bottom ellipse (shadow side)
    ctx.beginPath(); ctx.ellipse(x + w/2, topY + h, w/2, ery, 0, 0, Math.PI * 2);
    ctx.fillStyle = _darken3D(colList[colList.length-1] || c1, 0.5); ctx.fill(); outln(0.5);
    // Top ellipse with radial highlight
    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, _lighten3D(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);
  };

  // Draw a cone: face + base ellipse + highlight
  const drawCone = (cx2, apexY, baseY, w, colList) => {
    const ery = (baseY - apexY) * 0.10;
    const bx  = cx2 - w/2;
    // Body
    _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);
    // Glow then outline
    ctx.beginPath(); ctx.moveTo(cx2, apexY); ctx.lineTo(cx2+w/2, baseY); ctx.lineTo(cx2-w/2, baseY); ctx.closePath();
    _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.40)'; ctx.lineWidth = 1.0; ctx.stroke(); _noGlow();
    ctx.beginPath(); ctx.moveTo(cx2, apexY); ctx.lineTo(cx2+w/2, baseY); ctx.lineTo(cx2-w/2, baseY); ctx.closePath();
    outln(0.9);
    // Base ellipse
    ctx.beginPath(); ctx.ellipse(cx2, baseY, w/2, ery, 0, 0, Math.PI * 2);
    ctx.fillStyle = _darken3D(colList[colList.length-1] || c1, 0.40); ctx.fill(); outln(0.5);
    // Left-face highlight streak
    ctx.beginPath();
    ctx.moveTo(cx2 - w * 0.05, apexY + (baseY-apexY)*0.08);
    ctx.lineTo(cx2 - w * 0.28, baseY - ery);
    ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = w * 0.07; ctx.stroke();
  };

  // Draw a sphere with radial gradient (multi-colour = horizontal stripes)
  const drawSphere = (cx2, cy2, r, colList) => {
    // Stripes via clip
    if (colList.length > 1) {
      const bandH = r * 2 / colList.length;
      colList.forEach((col, i) => {
        ctx.save();
        ctx.beginPath(); ctx.rect(cx2-r, cy2-r + i*bandH, r*2, bandH+1); ctx.clip();
        ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2);
        ctx.fillStyle = col; ctx.fill();
        ctx.restore();
      });
    } else {
      ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2);
      ctx.fillStyle = colList[0] || '#78909c'; ctx.fill();
    }
    // 3D highlight overlay
    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);
  };

  // ── TOPMARKS per IALA ──────────────────────────────────────────────────
  // PORT mark → square can topmark (cube face, viewed from front)
  const drawTopmarkCan = (tx, ty, w, col) => {
    const h = w;   // SQUARE — same width and height
    drawCylinder(tx - w/2, ty - h, w, h, [col]);
  };
  // STBD mark → conical topmark point-up
  const drawTopmarkCone = (tx, ty, w, col) => {
    const h = w * 0.9;
    drawCone(tx, ty - h, ty, w, [col]);
  };
  // Generic cone-up topmark (for other buoys)
  const drawTopmarkConeSmall = (tx, ty, w, col) => {
    const h = w * 0.7; drawCone(tx, ty - h, ty, w, [col]);
  };

  // ── DRAW by shape ──────────────────────────────────────────────────────
  if (isCan) {
    // ── CAN (Cilíndrica) — 3D cylinder ────────────────────────
    drawCylinder(cx - bW/2, bTop, bW, bH, css.length ? css : [c1]);
    _st(ctx, cx, bTop - eRy, stTop);
    // PORT topmark = square can (same width as topmark reservation)
    drawTopmarkCan(cx, stTop, bW * 0.42, c1);

  } else if (isCone) {
    // ── CONE (Cónica) — 3D cone ────────────────────────────────
    drawCone(cx, bTop, bBot, bW, css.length ? css : [c1]);
    _st(ctx, cx, bTop, stTop);
    // STBD topmark = conical point-up
    drawTopmarkCone(cx, stTop, bW * 0.44, c1);

  } else if (isSph) {
    // ── SPHERE (Esférica) — 3D ball ────────────────────────────
    const r = bH * 0.44, bcy = bTop + r;
    drawSphere(cx, bcy, r, css.length ? css : [c1]);
    _st(ctx, cx, bcy - r, stTop);
    // Topmark: red sphere (safe water style)
    const tmR = sz * 0.09;
    drawSphere(cx, stTop - tmR, tmR, [c1]);

  } else if (isSpar) {
    // ── SPAR / ESPEQUE — tall thin cylinder ────────────────────
    const sw = bW * 0.24, sparTop = bTop - bH * 0.40;
    drawCylinder(cx - sw/2, sparTop, sw, bH * 1.40, css.length ? css : [c1]);
    // Topmark: spar lateral = same rule por catlam
    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 if (isBar) {
    // ── BARREL (Barril) — fat ellipsoid with stave lines ───────
    const rx = bW * 0.52, ry = bH * 0.46, bcy = bTop + ry;
    // Body fill with sphere-style 3D
    drawSphere(cx, bcy, Math.min(rx, ry), css.length ? css : [c1]);
    // Barrel top cap ellipse
    const tg2 = ctx.createRadialGradient(cx - rx*0.3, bcy-ry, 2, cx, bcy-ry, rx);
    tg2.addColorStop(0, 'rgba(255,255,255,0.65)'); tg2.addColorStop(1, _lighten3D(c1, 0.2));
    ctx.beginPath(); ctx.ellipse(cx, bcy - ry, rx * 0.55, ry * 0.16, 0, 0, Math.PI*2);
    ctx.fillStyle = tg2; ctx.fill(); outln(0.5);
    // Barrel bottom cap ellipse
    ctx.beginPath(); ctx.ellipse(cx, bcy + ry, rx * 0.55, ry * 0.16, 0, 0, Math.PI*2);
    ctx.fillStyle = _darken3D(c1, 0.35); ctx.fill(); outln(0.5);
    // Barrel stave ring lines (3 horizontal bands)
    [0.32, 0.50, 0.68].forEach(t => {
      ctx.beginPath(); ctx.ellipse(cx, bcy - ry + ry*2*t, rx * 0.52, ry * 0.08, 0, 0, Math.PI*2);
      ctx.strokeStyle = _darken3D(c1, 0.45); ctx.lineWidth = 0.7; ctx.stroke();
    });
    _st(ctx, cx, bcy - ry, stTop);
    if (cl === 1 || cl === 3)      drawTopmarkCan (cx, stTop, bW * 0.40, c1);
    else if (cl === 2 || cl === 4) drawTopmarkCone(cx, stTop, bW * 0.42, c1);
    else                           drawTopmarkConeSmall(cx, stTop, bW * 0.44, c1);

  } else if (isSuper) {
    // ── SUPER-BUOY / LANBY — wide low platform + tower ─────────
    const fw = bW * 1.10, fh = bH * 0.55;
    const fy = bBot - fh;
    // Lower hull (flat cylinder)
    drawCylinder(cx - fw/2, fy, fw, fh, css.length ? css : [c1]);
    // Tower / mast on top
    const tw = bW * 0.28, th = bH * 0.60;
    drawCylinder(cx - tw/2, fy - th, tw, th, [_darken3D(c1, 0.10)]);
    // Light housing at top of tower (small sphere)
    drawSphere(cx, fy - th - sz*0.07, sz*0.07, [_lighten3D(c1, 0.3)]);
    // Staff tip
    _st(ctx, cx, fy - th - sz*0.14, stTop);

  } else {
    // ── PILLAR (BOYSHP=4 or unknown) — hull + superstructure ───
    const pw = bW * 0.48;
    // Lower rounded hull (trapezoid wider at bottom)
    drawCylinder(cx - pw/2, bTop + bH*0.4, pw, bH*0.6, css.length ? css : [c1]);
    // Upper narrower superstructure
    const sw2 = pw * 0.7;
    drawCylinder(cx - sw2/2, bTop, sw2, bH*0.4, [_darken3D(c1, 0.08)]);
    _st(ctx, cx, bTop, stTop);
    // Topmark depends on lateral significance (catlam), NOT on hull shape.
    // PORT (babor,  catlam 1/3) → cuadrado (can)
    // STBD (estribor, catlam 2/4) → cono (cone)
    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);
  }

  // ── Waterline ──────────────────────────────────────────────────────────
  _wl(ctx, cx, wlY, bW * 0.68);

  // ── Light flare (S-52 magenta, when buoy has a light) ─────────────────
  if (opts.hasLight) {
    _drawLightFlare(ctx, cx, stTop + sz * 0.02, css[0] || '#ffffff');
  }

  return c;
}

// ── Cardinal buoy (BOYCAR) — black/yellow banded pillar + two-cone topmark ──
// N=▲▲  S=▼▼  E=▲▼(base-base)  W=▼▲(point-point)
// Body banding: N=Blk/Yel  S=Yel/Blk  E=Blk/Yel/Blk  W=Yel/Blk/Yel
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;

  // Body banding
  const bands = {
    N:['#111','#f9a825'], NE:['#111','#f9a825'], NW:['#111','#f9a825'],
    S:['#f9a825','#111'], SE:['#f9a825','#111'], SW:['#f9a825','#111'],
    E:['#111','#f9a825','#111'], W:['#f9a825','#111','#f9a825'],
  }[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);

  // Two-cone topmark
  const cw = sz * 0.36, ch = sz * 0.14;
  const t1 = sTop - ch * 0.2;        // lower cone top y
  const t2 = t1 - ch - sz * 0.01;    // upper cone top y
  if (quadrant === 'N' || quadrant === 'NE' || quadrant === 'NW') {
    _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK);
  } else if (quadrant === 'S' || quadrant === 'SE' || quadrant === 'SW') {
    _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 (BOYISD) — black + red band + two black spheres ────────
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 (BOYSAW) — red/white vertical stripes + red sphere ──────────
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;

  // Vertical red/white stripes clipped to body rect
  ctx.save();
  ctx.beginPath(); ctx.rect(cx - bW/2, bTop, bW, bH); ctx.clip();
  const stripes = 4, sw = bW / stripes;
  for (let i = 0; i < stripes; i++) {
    ctx.fillStyle = i % 2 === 0 ? '#e53935' : '#ffffff';
    ctx.fillRect(cx - bW/2 + i * sw, bTop, sw, 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 Mark (BOYSPP) — yellow body + yellow X topmark ────────────────
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.1, '#f9a825');
  _wl(ctx, cx, wlY, bW * 0.65);
  return c;
}

// ── Special Beacon (BCNSPP) — poste + cuerpo amarillo + tope X ───────────
// Misma estructura que BCNLAT pero: cuerpo amarillo, topmark = cruz X amarilla.
function _encBcnSppCanvas(sz = 36) {
  const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
  const col = '#f9a825';

  const groundY  = sz * 0.92;
  const poleTopY = sz * 0.04;
  const poleW    = sz * 0.055;
  const bodyW    = sz * 0.28;
  const bodyTopY = sz * 0.48;
  const bodyBotY = groundY;
  const tmW      = sz * 0.36;
  const tmBotY   = sz * 0.27;
  const tmTopY   = sz * 0.05;
  const tmCY     = (tmTopY + tmBotY) / 2;
  const tmR      = (tmBotY - tmTopY) * 0.38;  // radio del X

  // Sombra base
  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();

  // Poste gris
  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);

  // Cuerpo amarillo (gradiente 3D)
  const bodyGrd = ctx.createLinearGradient(cx - bodyW / 2, 0, cx + bodyW / 2, 0);
  bodyGrd.addColorStop(0,    _lighten3D(col, 0.35));
  bodyGrd.addColorStop(0.45, col);
  bodyGrd.addColorStop(1,    _darken3D(col, 0.28));
  ctx.strokeStyle = 'rgba(0,0,0,0.65)'; ctx.lineWidth = 0.9;
  ctx.fillStyle = bodyGrd;
  ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
  ctx.strokeRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
  // Highlight lateral
  ctx.fillStyle = 'rgba(255,255,255,0.15)';
  ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY);

  // Topmark X — contorno oscuro primero, luego amarillo encima
  ctx.lineCap = 'round';
  ctx.lineWidth = sz * 0.10; ctx.strokeStyle = 'rgba(0,0,0,0.45)';
  ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke();
  ctx.lineWidth = sz * 0.075; ctx.strokeStyle = col;
  ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke();

  return c;
}

/* ═══════════════════════════════════════════════════════════════════════════
   IALA buoy canvases — exact port of AR ECDIS SVG defs (ui/js/map.js).
   Each function returns a canvas sized scale × SVG viewBox, with the
   position dot at the bottom anchor (white halo + black inner).
   anchor=[0.5, 1.0] in the OL Icon style places that dot on the geo point.
   ═══════════════════════════════════════════════════════════════════════════ */

const _IALA_S = 0.85;  // canvas px per SVG viewBox unit (slightly smaller than native)

function _ialaCanvas(svgW, svgH) {
  const c = document.createElement('canvas');
  c.width  = Math.round(svgW * _IALA_S);
  c.height = Math.round(svgH * _IALA_S);
  return c;
}

function _ialaPosDot(ctx, cx, cy) {
  const s = _IALA_S;
  ctx.beginPath(); ctx.arc(cx*s, cy*s, 3*s, 0, Math.PI * 2);
  ctx.fillStyle = '#fff'; ctx.fill();
  ctx.beginPath(); ctx.arc(cx*s, cy*s, 1.5*s, 0, Math.PI * 2);
  ctx.fillStyle = '#111'; ctx.fill();
}

function _ialaRoundRect(ctx, x, y, w, h, r) {
  if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); }
  else {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
    ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
    ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
    ctx.closePath();
  }
}

// ── Lateral port — IALA-A: red can. IALA-B: green can. ────────────────────
function _ialaBoyLatPort(ialaB) {
  const c = _ialaCanvas(20, 30);
  const ctx = c.getContext('2d'); const s = _IALA_S;
  const top  = ialaB ? '#33dd33' : '#ee2222';
  const body = ialaB ? '#009900' : '#cc0000';
  const bot  = ialaB ? '#007700' : '#aa0000';
  ctx.lineWidth = 1; ctx.strokeStyle = '#111';
  // top ellipse
  ctx.beginPath(); ctx.ellipse(10*s, 7*s, 6*s, 2.5*s, 0, 0, Math.PI*2);
  ctx.fillStyle = top; ctx.fill(); ctx.stroke();
  // body
  _ialaRoundRect(ctx, 4*s, 7*s, 12*s, 14*s, 2*s);
  ctx.fillStyle = body; ctx.fill(); ctx.stroke();
  // bottom ellipse
  ctx.beginPath(); ctx.ellipse(10*s, 21*s, 6*s, 2*s, 0, 0, Math.PI*2);
  ctx.fillStyle = bot; ctx.fill(); ctx.stroke();
  _ialaPosDot(ctx, 10, 27);
  return c;
}

// ── Lateral starboard — IALA-A: green cone. IALA-B: red cone. ─────────────
function _ialaBoyLatStbd(ialaB) {
  const c = _ialaCanvas(20, 28);
  const ctx = c.getContext('2d'); const s = _IALA_S;
  const fill = ialaB ? '#cc0000' : '#009900';
  ctx.beginPath();
  ctx.moveTo(10*s, 2*s); ctx.lineTo(18*s, 21*s); ctx.lineTo(2*s, 21*s); ctx.closePath();
  ctx.fillStyle = fill; ctx.fill();
  ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke();
  _ialaPosDot(ctx, 10, 25);
  return c;
}

// ── Cardinal N/S/E/W — pillar + 2-cone topmark ────────────────────────────
function _ialaBoyCar(quadrant) {
  const isEW = quadrant === 'E' || quadrant === 'W';
  const svgH = isEW ? 42 : 40;
  const c = _ialaCanvas(20, svgH);
  const ctx = c.getContext('2d'); const s = _IALA_S;
  const BLK = '#111', YEL = '#ffdd00';
  ctx.lineWidth = 0.5*s; ctx.strokeStyle = BLK;

  const pole = (y1, y2) => {
    ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s;
    ctx.beginPath(); ctx.moveTo(10*s, y1*s); ctx.lineTo(10*s, y2*s); ctx.stroke();
    ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5*s;
  };
  const cone = (apex, baseY, baseLeft, baseRight, fill) => {
    ctx.beginPath();
    ctx.moveTo(apex[0]*s, apex[1]*s);
    ctx.lineTo(baseRight[0]*s, baseRight[1]*s);
    ctx.lineTo(baseLeft[0]*s, baseLeft[1]*s);
    ctx.closePath();
    ctx.fillStyle = fill; ctx.fill(); ctx.stroke();
  };

  if (quadrant === 'N') {
    cone([10,0], 7, [6,7],  [14,7],  BLK);
    cone([10,8], 15,[6,15], [14,15], BLK);
    pole(15, 19);
    ctx.fillStyle = BLK; ctx.fillRect(6*s, 19*s, 8*s, 6*s);
    ctx.fillStyle = YEL; ctx.fillRect(6*s, 25*s, 8*s, 6*s);
    ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 19*s, 8*s, 12*s);
    _ialaPosDot(ctx, 10, 37);
  } else if (quadrant === 'S') {
    // 2 cones pointing down
    ctx.beginPath();
    ctx.moveTo(6*s,0); ctx.lineTo(14*s,0); ctx.lineTo(10*s,7*s); ctx.closePath();
    ctx.fillStyle = BLK; ctx.fill(); ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(6*s,8*s); ctx.lineTo(14*s,8*s); ctx.lineTo(10*s,15*s); ctx.closePath();
    ctx.fillStyle = BLK; ctx.fill(); ctx.stroke();
    pole(15, 19);
    ctx.fillStyle = YEL; ctx.fillRect(6*s, 19*s, 8*s, 6*s);
    ctx.fillStyle = BLK; ctx.fillRect(6*s, 25*s, 8*s, 6*s);
    ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 19*s, 8*s, 12*s);
    _ialaPosDot(ctx, 10, 37);
  } else if (quadrant === 'E') {
    // diamond: cone up + cone down, bases joined
    ctx.beginPath();
    ctx.moveTo(10*s,0); ctx.lineTo(16*s,8*s); ctx.lineTo(10*s,16*s); ctx.lineTo(4*s,8*s); ctx.closePath();
    ctx.fillStyle = BLK; ctx.fill(); ctx.stroke();
    pole(16, 20);
    ctx.fillStyle = BLK; ctx.fillRect(6*s, 20*s, 8*s, 5*s);
    ctx.fillStyle = YEL; ctx.fillRect(6*s, 25*s, 8*s, 5*s);
    ctx.fillStyle = BLK; ctx.fillRect(6*s, 30*s, 8*s, 5*s);
    ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 20*s, 8*s, 15*s);
    _ialaPosDot(ctx, 10, 39);
  } else { // W
    // bowtie: cone down on top + cone up on bottom, points meet at y=8
    ctx.beginPath();
    ctx.moveTo(4*s,0); ctx.lineTo(16*s,0); ctx.lineTo(10*s,8*s); ctx.closePath();
    ctx.fillStyle = BLK; ctx.fill(); ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(4*s,16*s); ctx.lineTo(16*s,16*s); ctx.lineTo(10*s,8*s); ctx.closePath();
    ctx.fillStyle = BLK; ctx.fill(); ctx.stroke();
    pole(16, 20);
    ctx.fillStyle = YEL; ctx.fillRect(6*s, 20*s, 8*s, 5*s);
    ctx.fillStyle = BLK; ctx.fillRect(6*s, 25*s, 8*s, 5*s);
    ctx.fillStyle = YEL; ctx.fillRect(6*s, 30*s, 8*s, 5*s);
    ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 20*s, 8*s, 15*s);
    _ialaPosDot(ctx, 10, 39);
  }
  return c;
}

// ── Isolated Danger — BRB body + 2 black spheres on top ────────────────────
function _ialaBoyIsd() {
  const c = _ialaCanvas(22, 44);
  const ctx = c.getContext('2d'); const s = _IALA_S;
  const BLK = '#111', RED = '#cc0000';
  ctx.fillStyle = BLK;
  ctx.beginPath(); ctx.arc(11*s, 4*s,  4*s, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(11*s, 12*s, 4*s, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s;
  ctx.beginPath(); ctx.moveTo(11*s, 16*s); ctx.lineTo(11*s, 20*s); ctx.stroke();
  ctx.fillStyle = BLK; ctx.fillRect(5*s, 20*s, 12*s, 5*s);
  ctx.fillStyle = RED; ctx.fillRect(5*s, 25*s, 12*s, 5*s);
  ctx.fillStyle = BLK; ctx.fillRect(5*s, 30*s, 12*s, 5*s);
  _ialaPosDot(ctx, 11, 41);
  return c;
}

// ── Safe Water — RW horizontal bands + red sphere on top ───────────────────
function _ialaBoySaw() {
  const c = _ialaCanvas(18, 38);
  const ctx = c.getContext('2d'); const s = _IALA_S;
  const RED = '#cc0000';
  ctx.fillStyle = RED;
  ctx.beginPath(); ctx.arc(9*s, 4*s, 4*s, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke();
  ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s;
  ctx.beginPath(); ctx.moveTo(9*s, 8*s); ctx.lineTo(9*s, 12*s); ctx.stroke();
  ctx.fillStyle = RED;    ctx.fillRect(3*s, 12*s, 12*s, 5*s);
  ctx.fillStyle = '#fff'; ctx.fillRect(3*s, 17*s, 12*s, 5*s);
  ctx.fillStyle = RED;    ctx.fillRect(3*s, 22*s, 12*s, 5*s);
  ctx.fillStyle = '#fff'; ctx.fillRect(3*s, 27*s, 12*s, 3*s);
  ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5*s;
  ctx.strokeRect(3*s, 12*s, 12*s, 18*s);
  _ialaPosDot(ctx, 9, 35);
  return c;
}

// ── Special Mark — yellow square + yellow X topmark ───────────────────────
function _ialaBoySpp() {
  const c = _ialaCanvas(20, 32);
  const ctx = c.getContext('2d'); const s = _IALA_S;
  const YEL_DARK = '#cc9900', YEL = '#ffcc00';
  ctx.strokeStyle = YEL_DARK; ctx.lineWidth = 2.5*s; ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(6*s, 1*s);  ctx.lineTo(14*s, 9*s); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(14*s, 1*s); ctx.lineTo(6*s, 9*s);  ctx.stroke();
  ctx.lineCap = 'butt';
  ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s;
  ctx.beginPath(); ctx.moveTo(10*s, 9*s); ctx.lineTo(10*s, 13*s); ctx.stroke();
  _ialaRoundRect(ctx, 4*s, 13*s, 12*s, 12*s, 2*s);
  ctx.fillStyle = YEL; ctx.fill();
  ctx.strokeStyle = '#111'; ctx.lineWidth = 1*s; ctx.stroke();
  _ialaPosDot(ctx, 10, 29);
  return c;
}

// ── White-light structure symbol (LIGHTS blanco / faro de orilla) ─────────────
// Diseño: rectángulo cuerpo blanco con borde negro + estrella de 5 puntas centrada
// + lagrima estirada de color purpura que sale del costado superior hacia arriba
// a ~20° de la vertical (convención ECDIS / S-52 adaptada).
// Para luces de color (rojo/verde) se usa el símbolo de cuerpo de boya en _encLightCanvas.
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;

  // Sólo se invoca para luces blancas (kind==='flare'); defensivamente mapeamos colores.
  let col = '#ffffff';
  if (colourCode === 3)      col = '#dd1111';
  else if (colourCode === 4) col = '#00aa00';
  else if (colourCode === 6) col = '#ffcc00';

  // ── Cuerpo rectangulo (estructura del faro) ───────────────────────────────
  const bW = 14, bH = 17;
  const bTop = H * 0.50;          // tope del rectángulo
  const bBot = bTop + bH;         // base del rectángulo

  ctx.beginPath();
  ctx.rect(cx - bW / 2, bTop, bW, bH);
  ctx.fillStyle = col;            // blanco (o color para casos defensivos)
  ctx.fill();
  ctx.strokeStyle = '#111';
  ctx.lineWidth = 1.2;
  ctx.stroke();

  // ── Estrella de 5 puntas centrada en el rectángulo ────────────────────────
  const starX = cx, starY = bTop + bH / 2;
  const Ro = 4.5, Ri = 1.85;     // radio exterior / interior
  ctx.beginPath();
  for (let i = 0; i < 10; i++) {
    const ang = (i * Math.PI / 5) - Math.PI / 2;   // empieza arriba
    const rr = (i % 2 === 0) ? Ro : Ri;
    const px = starX + Math.cos(ang) * rr;
    const py = starY + Math.sin(ang) * rr;
    i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
  }
  ctx.closePath();
  ctx.fillStyle = (col === '#ffffff') ? '#222' : '#fff';  // contraste
  ctx.fill();

  // ── Lagrima estirada purpura (haz de luz) ─────────────────────────────────
  // Sale del costado superior-derecho del rectángulo, ~20° de la vertical.
  const PURP    = '#8800cc';
  const tearAng = 20 * Math.PI / 180;     // 20° desde la vertical
  const tearLen = 18;                      // longitud de la lagrima (px)

  ctx.save();
  // Ancla en el extremo superior-derecho del rectángulo
  ctx.translate(cx + bW / 2 - 1, bTop + 1);
  ctx.rotate(tearAng);                     // rotar 20° hacia la derecha

  // Forma de lagrima: estrecha en la base, ancha a la mitad, punta arriba
  ctx.beginPath();
  ctx.moveTo(0, 0);                                       // base (anclaje)
  ctx.bezierCurveTo(-3.8, -5,  -3.8, -11,  0, -tearLen);// lado izquierdo
  ctx.bezierCurveTo( 3.8, -11,  3.8, -5,   0, 0);       // lado derecho
  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();

  // ── Punto de anclaje geográfico en la base ────────────────────────────────
  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;
}

// IHO S-52 "magenta flare" — stylised flash extending up-right from the
// light's position. The flare is always magenta (chart convention for "this
// is a light symbol"); the actual colour of the emitted light is shown as a
// small coloured dot at the apex of the flare.
function _drawLightFlare(ctx, cx, y, col) {
  const MAG = '#ff00aa';      // S-52 magenta
  const len = 8;              // flare length (px)
  ctx.save();
  ctx.translate(cx, y);
  // Flare: thin triangular wedge at 45° up-right
  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();
  // Position dot at the light's geographic point
  ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2);
  ctx.fillStyle = MAG; ctx.fill();
  // Coloured tip: shows the actual emitted-light colour
  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();
}

// ── Lighted aid (LIGHTS feature) — infer lateral/special from colour + region ─
// S-57 LIGHTS objects often appear without a paired BOYLAT structure. We infer
// the host's shape from the light's COLOUR + the cell's IALA region:
//   IALA-B: green light → port can ; red light → stbd nun
//   IALA-A: green light → stbd nun ; red light → port can
//   yellow → SPECIAL ; white/magenta/null → simple flare (no shape inferred)
function _encLightCanvas(colours, region, sz = 32) {
  const raw = colours[0];
  const ialaB = (region || 'B') === 'B';
  // Decide implied lateral significance from the light's colour
  let kind = 'flare';   // default
  if (raw === 4)        kind = ialaB ? 'can'  : 'cone';   // green
  else if (raw === 3)   kind = ialaB ? 'cone' : 'can';    // red
  else if (raw === 6)   kind = 'special';                 // yellow

  // Pure light feature — render the S-52 radial-cross symbol (ECDIS style).
  if (kind === 'flare') {
    return _ialaLight(raw);
  }

  // Otherwise the light is hosted on a buoy shape (can/cone/special).
  const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
  const col = (!raw || raw === 1) ? '#cc00cc' : _s57css(raw);

  // Draw a small body silhouette with the light dot perched on its top.
  const bBot = sz * 0.86, bTop = sz * 0.50, bW = sz * 0.42, bH = bBot - bTop;
  if (kind === 'can') {
    ctx.fillStyle = col; ctx.fillRect(cx - bW/2, bTop, bW, bH);
    ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH);
  } else if (kind === 'cone') {
    ctx.beginPath();
    ctx.moveTo(cx, bTop); ctx.lineTo(cx + bW/2, bBot); ctx.lineTo(cx - bW/2, bBot); ctx.closePath();
    ctx.fillStyle = col; ctx.fill();
    ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.stroke();
  } else if (kind === 'special') {
    // Yellow body, X topmark style — borrowed from BOYSPP look
    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);
    _tmX(ctx, cx, bTop - sz * 0.06, sz * 0.08, '#f9a825');
  }
  // Waterline
  _wl(ctx, cx, sz * 0.90, bW * 0.7);
  // Light flare sits at the top of the body
  _drawLightFlare(ctx, cx, bTop - sz * 0.10, col);
  return c;
}

// ── Lateral beacon (BCNLAT) — poste + cuerpo + HUECO + marca de tope ─────────
// Estructura real IALA: palo delgado continuo + cuerpo rectangular en la parte
// inferior + tramo de palo libre (hueco visible) + marca de tope separada arriba.
//   Babor  (port, IALA-B verde):    marca de tope = cuadrado.
//   Estribor (stbd, IALA-B rojo):   marca de tope = triángulo apuntando arriba.
// La marca de tope es levemente más ancha que el cuerpo y queda separada de él.
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 col = cc[0] ? _s57css(cc[0]) : '#78909c';
  const isPort = catlam === 1 || catlam === 3;   // IALA-B: babor=verde, estribor=rojo

  // ── Layout: poste continuo + cuerpo (bajo) + hueco + marca de tope (arriba) ─
  const groundY  = sz * 0.92;   // base / nivel del suelo
  const poleTopY = sz * 0.04;   // cima del poste
  const poleW    = sz * 0.055;  // grosor del poste delgado

  // Cuerpo (shaft): rectángulo en la mitad inferior del poste
  const bodyW    = sz * 0.28;
  const bodyTopY = sz * 0.48;   // tope del cuerpo → aquí empieza el hueco visible
  const bodyBotY = groundY;

  // Marca de tope: separada del cuerpo, levemente más ancha
  const tmW      = sz * 0.36;   // ancho de la marca de tope
  const tmBotY   = sz * 0.27;   // base de la marca (límite superior del hueco)
  const tmTopY   = sz * 0.05;   // cima de la marca (= ≈ tope del poste)
  const tmH      = tmBotY - tmTopY;

  // ── Sombra elíptica en la base ────────────────────────────────────────────
  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();

  // ── Poste continuo (todo el alto, dibujado primero = detrás) ─────────────
  const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0);
  poleGrd.addColorStop(0,   '#999');
  poleGrd.addColorStop(0.4, '#ddd');
  poleGrd.addColorStop(1,   '#777');
  ctx.beginPath();
  ctx.rect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY);
  ctx.fillStyle = poleGrd;
  ctx.fill();

  // ── Gradiente de color IALA (compartido por cuerpo y marca de tope) ───────
  const mkGrd = (w) => {
    const g = ctx.createLinearGradient(cx - w / 2, 0, cx + w / 2, 0);
    g.addColorStop(0,    _lighten3D(col, 0.35));
    g.addColorStop(0.45, col);
    g.addColorStop(1,    _darken3D(col, 0.28));
    return g;
  };

  ctx.strokeStyle = 'rgba(0,0,0,0.65)';
  ctx.lineWidth   = 0.9;

  // ── Cuerpo (shaft rectangulo, parte inferior) ─────────────────────────────
  ctx.beginPath();
  ctx.rect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
  ctx.fillStyle = mkGrd(bodyW);
  ctx.fill();
  ctx.stroke();
  // Highlight lateral izquierdo
  ctx.beginPath();
  ctx.rect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY);
  ctx.fillStyle = 'rgba(255,255,255,0.15)';
  ctx.fill();

  // ── Marca de tope (separada del cuerpo por el hueco = tramo de poste libre) ─
  if (isPort) {
    // Babor: cuadrado (square daymark)
    ctx.beginPath();
    ctx.rect(cx - tmW / 2, tmTopY, tmW, tmH);
    ctx.fillStyle = mkGrd(tmW);
    ctx.fill();
    ctx.stroke();
    // Highlight lateral
    ctx.beginPath();
    ctx.rect(cx - tmW / 2, tmTopY, tmW * 0.20, tmH);
    ctx.fillStyle = 'rgba(255,255,255,0.15)';
    ctx.fill();
  } else {
    // Estribor: triángulo apuntando hacia arriba (triangle daymark)
    ctx.beginPath();
    ctx.moveTo(cx,            tmTopY);   // vértice superior (apex)
    ctx.lineTo(cx + tmW / 2,  tmBotY);  // esquina inferior derecha
    ctx.lineTo(cx - tmW / 2,  tmBotY);  // esquina inferior izquierda
    ctx.closePath();
    ctx.fillStyle = mkGrd(tmW);
    ctx.fill();
    ctx.stroke();
  }

  return c;
}

// ── Range / Leading-mark dayboard — dos triángulos concéntricos ──────────────
// Símbolo IALA: triángulo exterior BLANCO + interior NEGRO con lados
// estrictamente paralelos (triángulo similar centrado en el baricentro).
// Tamaño reducido para no dominar el mapa.
function _encRangeDaymarkCanvas(colours, hasLight, sz = 34) {
  const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;

  // Light colour for optional flare
  const lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff';

  // ── Layout ───────────────────────────────────────────────────────────────
  const triTop = sz * 0.06;   // apex of outer triangle
  const triBot = sz * 0.94;   // base of outer triangle
  const halfW  = sz * 0.44;   // half-width at base

  // ── OUTER triangle — WHITE ───────────────────────────────────────────────
  ctx.beginPath();
  ctx.moveTo(cx, triTop);
  ctx.lineTo(cx + halfW, triBot);
  ctx.lineTo(cx - halfW, triBot);
  ctx.closePath();
  ctx.fillStyle = '#ffffff';
  ctx.fill();
  ctx.strokeStyle = '#555';
  ctx.lineWidth = 1.0;
  ctx.stroke();

  // ── INNER triangle — BLACK, lados paralelos al exterior ──────────────────
  // Escalar desde el baricentro garantiza lados paralelos (triángulo similar).
  const r  = 0.48;                              // escala del interior
  const cy = (triTop + 2 * triBot) / 3;        // baricentro Y del triángulo exterior
  const iApexY =  cy + r * (triTop - cy);      // vértice superior del interior
  const iBaseY =  cy + r * (triBot - cy);      // base del interior
  const iHalf  =  halfW * r;                   // media-anchura → lados paralelos
  ctx.beginPath();
  ctx.moveTo(cx,         iApexY);
  ctx.lineTo(cx + iHalf, iBaseY);
  ctx.lineTo(cx - iHalf, iBaseY);
  ctx.closePath();
  ctx.fillStyle = '#111111';
  ctx.fill();
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 0.6;
  ctx.stroke();

  // ── Light flare al ápice (si la feature tiene luz) ───────────────────────
  if (hasLight) {
    _drawLightFlare(ctx, cx, triTop - sz * 0.02, lightCss);
  }

  return c;
}

// ── Cardinal beacon (BCNCAR) — tripod + two-cone topmark ─────────────────────
function _encBcnCardinalCanvas(quadrant, sz = 30) {
  const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
  const BLK = '#111'; const baseY = sz * 0.90, poleTop = sz * 0.30;

  ctx.strokeStyle = '#444'; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx - sz*0.22, baseY); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx + sz*0.22, baseY); ctx.stroke();
  ctx.strokeStyle = '#444'; ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(cx, baseY); ctx.lineTo(cx, poleTop); ctx.stroke();

  const cw = sz * 0.42, ch = sz * 0.16;
  const t1 = poleTop - sz * 0.02;
  const 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);
  }
  return c;
}

/* ═══════════════════════════════════════════════════════════════════════════
   Multi-cell composition (port from AR ECDIS).
   - usage_band derived from the 3rd char of the cell id (IHO/NOAA convention):
       US1GC09M → 1 Overview, US5MIA* → 5 Harbour, etc.
   - Each band is rendered only inside its own zoom range. Cells with band 0
     (unknown) are always rendered.
   - When a higher-band cell covers ≥40% of the viewport, lower-band cells are
     hidden — so the harbour cell takes over while the overview stays loaded
     for panning to less detailed areas.
   ═══════════════════════════════════════════════════════════════════════════ */
window._chartCells = {};                  // cell_id → { bbox: [w,s,e,n], band: int }
const _HIDDEN_CELLS = new Set();          // mutated on moveend by _recomputeHiddenCells

// Band zoom ranges: [minZoom, maxZoom].
// Band 0 (custom/BARRANQUILLA) siempre visible [0,24].
// Bands 1-4 tienen maxZoom para que celdas overview no compitan con harbour
// en zoom alto — sin estos límites, US1GC09M (549 features Overview) aparece
// en zoom 11 sobre Miami y su declutter oculta las boyas de mayor resolución.
// BARRANQUILLA no se afecta — su cell_id da band=0 por _bandFromCellId.
const BAND_ZOOM_RANGES = {
  0: [0,  24],   // custom / unknown (ej. BARRANQUILLA) → siempre visible
  1: [0,  10],   // Overview         (1:600 000+) — se oculta a zoom >10
  2: [3,  12],   // General          (1:350 000)
  3: [6,  15],   // Coastal          (1:90 000)
  4: [8,  18],   // Approach         (1:25 000)
  5: [10, 24],   // Harbour          (1:7 500)
  6: [12, 24],   // Berthing         (1:2 000)
};

function _bandFromCellId(id) {
  const m = (id || '').match(/^[A-Za-z]{2}([1-6])/);
  return m ? +m[1] : 0;
}

// Returns false if this feature's cell should NOT render at the current resolution.
function _cellShouldRender(cellId, resolution) {
  if (!cellId) return true;
  if (_HIDDEN_CELLS.has(cellId)) return false;
  const meta = window._chartCells[cellId];
  const band = meta ? meta.band : _bandFromCellId(cellId);
  const zoom = map.getView().getZoomForResolution(resolution);
  const [zmin, zmax] = BAND_ZOOM_RANGES[band] || BAND_ZOOM_RANGES[0];
  return zoom >= zmin && zoom <= zmax;
}

// On moveend, hide overview cells whose viewport overlap is dominated by a
// higher-band cell (≥40% of the viewport area covered by the higher one).
// IMPORTANT: only count a higher-band cell as "dominating" if that cell is
// actually within its own zoom range at the current zoom. Otherwise band-4
// cells get hidden by a band-5 cell that is itself invisible (zoom < 10),
// leaving a gap where NO chart renders at all (the Miami disappear bug).
function _recomputeHiddenCells() {
  _HIDDEN_CELLS.clear();
  const cells = window._chartCells;
  if (!cells || Object.keys(cells).length === 0) return;
  const ext  = map.getView().calculateExtent(map.getSize());
  const zoom = map.getView().getZoom() || 0;
  const [w, s, e, n] = ol.proj.transformExtent(ext, 'EPSG:3857', 'EPSG:4326');
  const vArea = (e - w) * (n - s);
  if (vArea <= 0) return;

  for (const [cellId, meta] of Object.entries(cells)) {
    if (!meta.bbox) continue;
    // Custom / operator-installed cells (band 0) are NEVER hidden by any NOAA
    // overview cell. They represent the primary operational chart for the port
    // and must remain visible at all zoom levels.
    if (meta.band === 0) continue;
    for (const [otherId, other] of Object.entries(cells)) {
      if (otherId === cellId || !other.bbox) continue;
      if (other.band <= meta.band) continue;
      // Only treat 'other' as dominating if it is actually visible at current zoom.
      // Without this check, a harbour cell (band-5, minZoom=10) hides the approach
      // cell (band-4) even at zoom 9.x, where band-5 is itself out of range → nothing renders.
      const [ozMin, ozMax] = BAND_ZOOM_RANGES[other.band] || BAND_ZOOM_RANGES[0];
      if (zoom < ozMin || zoom > ozMax) continue;
      const iw = Math.max(0, Math.min(e, other.bbox[2]) - Math.max(w, other.bbox[0]));
      const ih = Math.max(0, Math.min(n, other.bbox[3]) - Math.max(s, other.bbox[1]));
      if ((iw * ih) / vArea >= 0.40) { _HIDDEN_CELLS.add(cellId); break; }
    }
  }
}

async function _loadChartCells() {
  try {
    const r = await fetch('/charts/cells');
    const cells = await r.json();
    cells.forEach(c => {
      window._chartCells[c.id] = {
        bbox: c.bbox,
        band: _bandFromCellId(c.id),
      };
    });
    _recomputeHiddenCells();
    [encSource, depthSource, landSource, zoneSource, hazardSource, soundSource]
      .forEach(src => src.changed());
  } catch (e) {
    console.warn('[charts] failed to load cell catalog:', e);
  }
}

// ── Leading line (enfilación) style ──────────────────────────────────────
// S-52: leading line = dashed orange/magenta line with arrowhead at the far
// end showing the bearing direction. The line is always rendered (no zoom
// threshold) so navigators can see it at any scale.
function _ldlineStyle(feature) {
  const orient  = feature.get('orient');         // True bearing, degrees
  const colours = feature.get('colours') || [];
  // Colour: use the light's own colour when available, else default magenta
  const col = colours[0] === 4 ? '#00cc44'       // green light → green line
            : colours[0] === 3 ? '#ff4444'        // red light   → red line
            : '#ff8800';                           // default orange (leading line)
  const lineStyle = new ol.style.Style({
    stroke: new ol.style.Stroke({
      color: col,
      width: 1.6,
      lineDash: [8, 5],
    }),
  });

  const styles = [lineStyle];

  // Arrowhead at the far end of the line (bearing direction)
  if (orient != null) {
    const coords = feature.getGeometry().getCoordinates();
    const end    = coords[coords.length - 1];
    const start  = coords[coords.length - 2];
    // Angle of the line in screen space (OL is EPSG:3857)
    const dx = end[0] - start[0], dy = end[1] - start[1];
    const angle = Math.atan2(dy, dx);   // radians, CCW from east
    // Small arrowhead canvas
    const asz = 14;
    const ac  = document.createElement('canvas');
    ac.width  = asz; ac.height = asz;
    const ax  = ac.getContext('2d');
    ax.strokeStyle = col; ax.lineWidth = 2; ax.lineCap = 'round';
    ax.translate(asz / 2, asz / 2);
    ax.rotate(-angle);                  // align with line direction
    ax.beginPath();
    ax.moveTo(-5, -4); ax.lineTo(0, 0); ax.lineTo(-5, 4);
    ax.stroke();
    styles.push(new ol.style.Style({
      geometry: new ol.geom.Point(end),
      image: new ol.style.Icon({
        img: ac, imgSize: [asz, asz],
        rotation: -angle,               // OL rotation is CW
        rotateWithView: true,
      }),
    }));
  }

  // Bearing label — always visible, centred on the line.
  // Format: "143.5°  E12 — E16" (bearing + paired mark names)
  const name    = feature.get('name') || '';
  const bearTxt = orient != null ? `${orient % 1 === 0 ? orient : orient.toFixed(1)}°` : '';
  const lbl     = [bearTxt, name].filter(Boolean).join('  ');
  if (lbl) {
    const coords = feature.getGeometry().getCoordinates();
    // Place label at 1/3 from start (near the front mark) so it's in the channel
    const idx = Math.max(0, Math.floor(coords.length / 3));
    const mid = coords[idx];
    styles.push(new ol.style.Style({
      geometry: new ol.geom.Point(mid),
      text: new ol.style.Text({
        text: lbl,
        font: '500 9px "Inter", "Segoe UI", sans-serif',
        fill:   new ol.style.Fill({ color: '#fff' }),
        offsetY: -12,
        overflow: true,
      }),
    }));
  }

  return styles;
}

// ── ENC (S-57 chart) style — dispatch by canonical aid_type ──────────────
// resolution is in m/pixel (OL passes it). Labels only show when zoomed in.
const ENC_LABEL_RES = 2.5;   // ≈ zoom 16; closer than that → show name
function encStyle(feature, resolution) {
  if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
  const layer    = (feature.get('layer') || '').toUpperCase();
  const aidType  = feature.get('aid_type') || 'UNKNOWN';
  const showName = resolution != null && resolution < ENC_LABEL_RES;
  const name     = showName ? (feature.get('name') || feature.get('nombre') || '') : '';
  const colours  = feature.get('colours') || [];
  const boyshp   = feature.get('boyshp');
  const catlam   = feature.get('catlam');
  const region   = feature.get('cell_region') || window.IALA_REGION || 'B';

  const isBcn    = layer.startsWith('BCN');
  const hasLight = !!(feature.get('light_desc'));
  const lightDesc = showName ? (feature.get('light_desc') || '') : '';
  // Compose on-map label: name on first line, light character on second
  const labelParts = [name, lightDesc].filter(Boolean);
  const label = labelParts.join('\n');

  const ialaB   = (region || 'B') === 'B';
  const hasOrient = feature.get('orient') != null;  // feature defines a leading line bearing
  let canvas;

  // ── Range / Leading mark — dayboard symbol when ORIENT is present ────────
  // Only for LIGHTS and LNDMRK features (not buoys/beacons that may have
  // inherited orient from a nearby light via the proximity merge).
  const isRangeMark = hasOrient
    && aidType !== 'LEADING_LINE'
    && !layer.startsWith('BOY')
    && !layer.startsWith('BCN');
  if (isRangeMark) {
    canvas = _cachedIcon(`rangemark_${colours[0]}_${hasLight}`,
      () => _encRangeDaymarkCanvas(colours, hasLight));
    // Switch is skipped below (canvas already set)
  }

  // Helper: add light flare to one of the fixed IALA canvas symbols
  const _withFlare = (baseFn, flareY, lightCss) => {
    const base = baseFn();
    if (!hasLight) return base;
    const ct = base.getContext('2d');
    _drawLightFlare(ct, base.width / 2, flareY, lightCss || '#ffffff');
    return base;
  };
  const _lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff';

  // Dispatch by aid_type (skipped when isRangeMark already set canvas above).
  if (!canvas) switch (aidType) {
    // ── Lateral buoys — route through _encBuoyCanvas (BOYSHP-aware, 3-D) ──
    case 'LATERAL_PORT':
    case 'LATERAL_PREF_STBD':
    case 'LATERAL_UNKNOWN': {
      if (isBcn) {
        canvas = _cachedIcon(`bcn_port_${colours.join('-')}_${region}`,
          () => _encBeaconCanvas(colours, catlam, region));
      } else {
        const shp = boyshp || 2;  // default: can for port
        canvas = _cachedIcon(`boy_port_${colours.join('-')}_${shp}_${region}_${hasLight}`,
          () => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight }));
      }
      break;
    }
    case 'LATERAL_STBD':
    case 'LATERAL_PREF_PORT': {
      if (isBcn) {
        canvas = _cachedIcon(`bcn_stbd_${colours.join('-')}_${region}`,
          () => _encBeaconCanvas(colours, catlam, region));
      } else {
        const shp = boyshp || 1;  // default: conical for starboard
        canvas = _cachedIcon(`boy_stbd_${colours.join('-')}_${shp}_${region}_${hasLight}`,
          () => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight }));
      }
      break;
    }
    // ── Cardinal buoys ──────────────────────────────────────────────────────
    // Usan _encCardinalCanvas (3-D, sz=48) para igualar el tamaño de las boyas
    // laterales (_encBuoyCanvas sz=52). Las funciones IALA SVG-port producían
    // un canvas de ~17×35 px que resultaba demasiado pequeño en el mapa.
    case 'CARDINAL_N':
    case 'CARDINAL_E':
    case 'CARDINAL_S':
    case 'CARDINAL_W': {
      const q = aidType.split('_')[1];
      canvas = _cachedIcon(`enc_car_${q}_${hasLight}`, () => {
        const cv = _encCardinalCanvas(q, 48);
        if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2,
          cv.height * 0.08, _lightCss);
        return cv;
      });
      break;
    }
    case 'CARDINAL_UNKNOWN':
      canvas = _cachedIcon(`enc_car_unk_${hasLight}`, () => {
        const cv = _encCardinalCanvas('N', 48);
        if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2,
          cv.height * 0.08, _lightCss);
        return cv;
      });
      break;
    // ── Other buoy types ────────────────────────────────────────────────────
    case 'ISOLATED_DANGER':
      canvas = _cachedIcon(`enc_isd_${hasLight}`, () => {
        const cv = _encIsdCanvas(48);
        if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2,
          cv.height * 0.08, _lightCss);
        return cv;
      });
      break;
    case 'SAFE_WATER':
      canvas = _cachedIcon(`enc_saw_${hasLight}`, () => {
        const cv = _encSawCanvas(48);
        if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2,
          cv.height * 0.08, _lightCss);
        return cv;
      });
      break;
    case 'SPECIAL': {
      // BOYSPP (boya flotante) → _encSppCanvas  |  BCNSPP (baliza fija) → _encBcnSppCanvas
      if (isBcn) {
        canvas = _cachedIcon(`bcn_spp_${hasLight}`, () => {
          const cv = _encBcnSppCanvas(36);
          if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825');
          return cv;
        });
      } else {
        canvas = _cachedIcon(`boy_spp_${hasLight}`, () => {
          const cv = _encSppCanvas(48);
          if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825');
          return cv;
        });
      }
      break;
    }
    case 'LEADING_LINE':
      // Leading line — no canvas icon needed; pure line style handled below
      return _ldlineStyle(feature);
    case 'LIGHT_POINT':
      canvas = _cachedIcon(`enc_light_${colours[0]}_${region}`,
                           () => _encLightCanvas(colours, region));
      break;
    // ── Generic / fallback ──────────────────────────────────────────────────
    case 'LANDMARK':
    case 'RACON':
    case 'BEACON_GENERIC': {
      canvas = _cachedIcon(`bcn_gen_${colours.join('-')}_${region}_${hasLight}`,
        () => _encBeaconCanvas(colours, catlam, region));
      break;
    }
    case 'BUOY_GENERIC':
    default: {
      // Always route through _encBuoyCanvas so every buoy gets 3-D symbology.
      // boyshp defaults to 4 (pillar) when unknown — visible & recognisable.
      const shp = boyshp || 4;
      canvas = _cachedIcon(`boy_gen_${colours.join('-')}_${shp}_${hasLight}`,
        () => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight }));
    }
  }

  // Safety net: if any draw function threw, canvas may be null. Fall back to
  // a small colored circle so the feature is still visible and clickable.
  if (!canvas) {
    const fc = document.createElement('canvas'); fc.width = fc.height = 16;
    const fx = fc.getContext('2d');
    fx.beginPath(); fx.arc(8, 8, 7, 0, Math.PI * 2);
    fx.fillStyle = colours[0] ? _s57css(colours[0]) : '#607d8b'; fx.fill();
    fx.strokeStyle = '#fff'; fx.lineWidth = 1.5; fx.stroke();
    canvas = fc;
    console.warn('[ENC] canvas fallback for', layer, aidType, colours);
  }

  // Compute zoom from resolution (deterministic, no map object access needed).
  // OL WebMercator: resolution ≈ 156543 / 2^zoom → zoom = log2(156543/res)
  const _zr = resolution ? Math.log2(156543.03392 / Math.max(resolution, 0.001)) : 14;
  // Scale: 0.168 at zoom 6, grows to 0.624 at zoom 14+. (~20 % bigger than
  // the previous 0.14–0.52 range to improve readability in busy harbours.)
  // Smooth curve — symbols shrink as user zooms out instead of vanishing.
  const iconScale = Math.max(0.168, Math.min(0.624, 0.168 + (_zr - 6) * 0.057));

  // Position dot — small circle rendered at the exact geographic coordinate.
  // Anchor is [0.5, 1.0] so the dot sits precisely at the buoy's lat/lon.
  const dotStyle = new ol.style.Style({
    image: new ol.style.Circle({
      radius: 2,
      fill:   new ol.style.Fill({ color: 'rgba(10,26,46,0.9)' }),
      stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 0.8 }),
    }),
  });

  const iconStyle = new ol.style.Style({
    image: new ol.style.Icon({
      img: canvas, imgSize: [canvas.width, canvas.height],
      scale: iconScale,
      anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
    }),
    // Persistent label removed — hover tooltip shows name + light + range.
    // Avoids visual clutter at busy harbours like BAQ / CTG.
  });

  return [iconStyle, dotStyle];
}

// ── Bathymetry rendering — IHO S-52 / NOAA convention ────────────────────
// Deeper water = paler / nearly white. Shallower = darker blue. Very shallow
// (<2 m, "drying heights") gets a warm tint to scream "danger".
function _depthFill(d) {
  // Tones taken from IHO INT 1 / NOAA paper charts: a soft "blue tint"
  // that gets paler with depth and turns to white below the safety contour.
  if (d == null)        return 'rgba(216,234,244,0.85)';    // unknown
  if (d <= 1)           return 'rgba(176,216,232,0.85)';    // ≤ 1 m  NOAA shallow tint
  if (d <  2)           return 'rgba(196,226,238,0.85)';    // 1 – 2 m
  if (d <  4)           return 'rgba(216,234,244,0.85)';    // 2 – 4 m
  if (d < 10)           return 'rgba(232,242,248,0.85)';    // 4 – 10 m
  return 'rgba(248,252,254,0.80)';                          // ≥ 10 m  near white
}

const _depthStyleCache = new Map();
function _depareStyle(depthMin) {
  const key = `dp_${depthMin}`;
  if (!_depthStyleCache.has(key)) {
    _depthStyleCache.set(key, new ol.style.Style({
      fill:   new ol.style.Fill({ color: _depthFill(depthMin) }),
      stroke: new ol.style.Stroke({ color: 'rgba(80,140,190,0.35)', width: 0.5 }),
    }));
  }
  return _depthStyleCache.get(key);
}

const _contourLine = new ol.style.Style({
  stroke: new ol.style.Stroke({ color: 'rgba(40,80,130,0.55)', width: 0.7 }),
});

function _contourStyle(depth, resolution) {
  // Depth label every ~600 px along the line, only at zoom ≥ 14 (res < 10 m/px)
  if (depth == null || resolution > 10) return _contourLine;
  return [
    _contourLine,
    new ol.style.Style({
      text: new ol.style.Text({
        text: depth.toString(),
        font: '500 9px "Inter", sans-serif',
        fill:   new ol.style.Fill({ color: '#1a3a5c' }),
        stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.85)', width: 2 }),
        placement: 'line',
        textBaseline: 'middle',
      }),
    }),
  ];
}

function _soundingStyle(depth, resolution) {
  // Numeric soundings — visible at most working zoom levels.
  if (depth == null || resolution > 30) return null;
  return new ol.style.Style({
    text: new ol.style.Text({
      text: depth.toString(),
      font: '500 10px "Inter", sans-serif',
      fill:   new ol.style.Fill({ color: '#1a3a5c' }),
      stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 2.5 }),
    }),
  });
}

// Solid-buff land style — paper-chart yellow. Only rendered in CHART mode
// so the regular DEPTHS toggle doesn't accidentally cover the OSM basemap.
let _chartModeActive = false;
const _landMaskStyle = new ol.style.Style({
  fill:   new ol.style.Fill({ color: 'rgba(248,231,184,1)' }),  // NOAA paper buff
  stroke: new ol.style.Stroke({ color: 'rgba(140,115,70,0.7)', width: 0.6 }),
});

function depthStyle(feature, resolution) {
  if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
  const layer = feature.get('layer');
  // DEPCNT contour lines always rendered — they're thin and don't cover OSM.
  // DEPARE polygons and LANDMASK fill paint opaque areas → restricted to
  // CHART mode where OSM is hidden.
  if (layer === 'DEPCNT')   return _contourStyle(feature.get('depth'), resolution);
  if (layer === 'DEPARE')   return _chartModeActive ? _depareStyle(feature.get('depth_min')) : null;
  if (layer === 'LANDMASK') return _chartModeActive ? _landMaskStyle : null;
  return null;
}

// SOUNDG soundings — rendered on top of land/buildings/aids (zIndex 8).
function soundStyle(feature, resolution) {
  if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
  return _soundingStyle(feature.get('depth'), resolution);
}

// ── Land / terrain styles ─────────────────────────────────────────────────────
const _LNDARE_STYLE = new ol.style.Style({
  fill:   new ol.style.Fill({ color: 'rgba(245,231,174,0.97)' }),
  stroke: new ol.style.Stroke({ color: 'rgba(140,115,70,0.5)', width: 0.5 }),
});
const _BUAARE_STYLE = new ol.style.Style({
  fill:   new ol.style.Fill({ color: 'rgba(215,200,160,0.97)' }),
  stroke: new ol.style.Stroke({ color: 'rgba(120,100,60,0.5)', width: 0.5 }),
});
const _COALNE_STYLE = new ol.style.Style({
  stroke: new ol.style.Stroke({ color: '#5a3e2b', width: 1.5 }),
});
// Buildings — S-52 LANDA tone with visible outline; name shown when zoomed in
function _buisglStyle(feature, resolution) {
  const name = resolution < 3 ? (feature.get('name') || '') : '';
  return new ol.style.Style({
    fill:   new ol.style.Fill({ color: 'rgba(188,160,110,0.92)' }),
    stroke: new ol.style.Stroke({ color: 'rgba(100,75,30,0.9)', width: 1.2 }),
    text: name ? new ol.style.Text({
      text: name,
      font: '500 9px "Inter", sans-serif',
      fill:   new ol.style.Fill({ color: '#3e2a06' }),
      stroke: new ol.style.Stroke({ color: 'rgba(255,240,200,0.85)', width: 2 }),
      overflow: true,
    }) : undefined,
  });
}

function _landPointCanvas(key, drawFn) {
  return _cachedIcon(key, drawFn);
}

function landStyle(feature, resolution) {
  if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
  const layer = feature.get('layer');
  if (layer === 'LNDARE')  return _LNDARE_STYLE;
  if (layer === 'BUAARE')  return _BUAARE_STYLE;
  if (layer === 'COALNE')  return _COALNE_STYLE;
  if (layer === 'BUISGL')  return _buisglStyle(feature, resolution);
  if (layer === 'BRIDGE') {
    const c = _landPointCanvas('lnd_bridge', () => {
      const cv = document.createElement('canvas'); cv.width = 16; cv.height = 10;
      const x = cv.getContext('2d');
      x.fillStyle = '#555'; x.fillRect(0, 3, 16, 4);
      x.strokeStyle = '#333'; x.lineWidth = 1; x.strokeRect(0, 3, 16, 4);
      return cv;
    });
    return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [c.width, c.height] }) });
  }
  if (layer === 'HRBFAC') {
    const c = _landPointCanvas('lnd_harbour', () => {
      const cv = document.createElement('canvas'); cv.width = cv.height = 16;
      const x = cv.getContext('2d');
      x.strokeStyle = '#1a5276'; x.lineWidth = 1.5;
      x.beginPath(); x.arc(8, 5, 3, 0, Math.PI * 2); x.stroke();
      x.beginPath(); x.moveTo(8, 8); x.lineTo(8, 15); x.stroke();
      x.beginPath(); x.moveTo(3, 12); x.lineTo(13, 12); x.stroke();
      return cv;
    });
    return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [16, 16] }) });
  }
  if (layer === 'BUISGL' || layer === 'SILTNK') {
    const c = _landPointCanvas(`lnd_${layer}`, () => {
      const cv = document.createElement('canvas'); cv.width = cv.height = 10;
      const x = cv.getContext('2d');
      x.fillStyle = '#aaa'; x.fillRect(1, 1, 8, 8);
      x.strokeStyle = '#666'; x.lineWidth = 1; x.strokeRect(1, 1, 8, 8);
      return cv;
    });
    return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [10, 10] }) });
  }
  return null;
}

// ── Hazard styles (IHO S-52 approximation) ────────────────────────────────────
const _WRECK_CANVAS = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 18;
  const x = c.getContext('2d');
  x.strokeStyle = '#7b0000'; x.lineWidth = 2;
  x.beginPath(); x.moveTo(3, 3); x.lineTo(15, 15); x.stroke();
  x.beginPath(); x.moveTo(15, 3); x.lineTo(3, 15); x.stroke();
  x.beginPath(); x.arc(9, 9, 4, 0, Math.PI * 2);
  x.strokeStyle = '#7b0000'; x.lineWidth = 1.5; x.stroke();
  return c;
})();
const _OBSTRN_CANVAS = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 16;
  const x = c.getContext('2d');
  x.strokeStyle = 'rgb(140,0,140)'; x.lineWidth = 1.5;
  x.beginPath(); x.arc(8, 8, 7, 0, Math.PI * 2); x.stroke();
  x.fillStyle = 'rgb(140,0,140)';
  x.beginPath(); x.arc(8, 8, 2.5, 0, Math.PI * 2); x.fill();
  return c;
})();
const _UWTROC_CANVAS = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 16;
  const x = c.getContext('2d');
  x.beginPath(); x.moveTo(8, 1); x.lineTo(15, 8); x.lineTo(8, 15); x.lineTo(1, 8); x.closePath();
  x.fillStyle = 'rgba(0,90,170,0.75)'; x.fill();
  x.strokeStyle = '#003366'; x.lineWidth = 1; x.stroke();
  return c;
})();
const _WRECK_STYLE  = new ol.style.Style({ image: new ol.style.Icon({ img: _WRECK_CANVAS,  imgSize: [18, 18] }) });
const _OBSTRN_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _OBSTRN_CANVAS, imgSize: [16, 16] }) });
const _UWTROC_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _UWTROC_CANVAS, imgSize: [16, 16] }) });

function hazardStyle(feature, resolution) {
  if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
  const layer = feature.get('layer');
  if (layer === 'WRECKS')  return _WRECK_STYLE;
  if (layer === 'OBSTRN')  return _OBSTRN_STYLE;
  if (layer === 'UWTROC')  return _UWTROC_STYLE;
  return null;
}

// ── Zone / area styles ────────────────────────────────────────────────────────
const _ZONE_CFG = {
  RESARE: { fill: 'rgba(200,0,0,0.07)',     stroke: '#cc0000', dash: [6,4], w: 1.5 },
  CTNARE: { fill: 'rgba(210,160,0,0.09)',   stroke: '#b8860a', dash: [6,4], w: 1.5 },
  ACHARE: { fill: 'rgba(0,70,180,0.07)',    stroke: '#0044bb', dash: [5,5], w: 1.5 },
  TSSLPT: { fill: 'rgba(0,130,0,0.07)',     stroke: '#006600', dash: null,  w: 1   },
  FAIRWY: { fill: 'rgba(0,110,120,0.07)',   stroke: '#005555', dash: [4,6], w: 1   },
  PRCARE: { fill: 'rgba(190,90,0,0.07)',    stroke: '#aa5500', dash: [6,4], w: 1.5 },
  DMPGRD: { fill: 'rgba(100,100,100,0.07)', stroke: '#555555', dash: [3,5], w: 1   },
  PIPARE: { fill: 'rgba(160,80,0,0.07)',    stroke: '#884400', dash: [2,4], w: 1   },
};
const _zoneStyleMap = new Map();
Object.entries(_ZONE_CFG).forEach(([layer, cfg]) => {
  _zoneStyleMap.set(layer, new ol.style.Style({
    fill:   new ol.style.Fill({ color: cfg.fill }),
    stroke: new ol.style.Stroke({ color: cfg.stroke, width: cfg.w,
                                   lineDash: cfg.dash || undefined }),
  }));
});

function zoneStyle(feature, resolution) {
  if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
  return _zoneStyleMap.get(feature.get('layer')) || null;
}

// Track the last bbox we requested so we can skip redundant fetches when the
// user pans only slightly. We expand the request bbox by a margin so small
// pans don't always trigger a refetch.
let _lastDepthBbox = null;

function _bboxContains(outer, inner) {
  if (!outer || !inner) return false;
  return outer[0] <= inner[0] && outer[1] <= inner[1]
      && outer[2] >= inner[2] && outer[3] >= inner[3];
}

window.loadChartDepths = async function() {
  if (!depthLayer.getVisible()) return;   // nothing to do if hidden
  const view = map.getView();
  const extent = view.calculateExtent(map.getSize());
  // Convert from map projection (EPSG:3857) to lon/lat
  const [w, s, e, n] = ol.proj.transformExtent(extent, view.getProjection(), 'EPSG:4326');
  // Skip if current cache already covers the visible viewport
  if (_bboxContains(_lastDepthBbox, [w, s, e, n])) return;
  // Request a slightly larger area than visible so small pans hit the cache
  const dx = (e - w) * 0.5, dy = (n - s) * 0.5;
  const W = w - dx, S = s - dy, E = e + dx, N = n + dy;
  try {
    const r = await fetch(`/charts/depths?w=${W}&s=${S}&e=${E}&n=${N}`);
    const fc = await r.json();
    depthSource.clear();
    soundSource.clear();
    if (fc.features?.length) {
      const fmt = new ol.format.GeoJSON();
      const all = fmt.readFeatures(fc, { featureProjection: 'EPSG:3857' });
      // Route SOUNDG (numbers) to soundLayer (zIndex 8) so soundings render
      // above terrain. Everything else stays in depthLayer (zIndex 2).
      const sound = [], rest = [];
      for (const f of all) {
        (f.get('layer') === 'SOUNDG' ? sound : rest).push(f);
      }
      if (rest.length)  depthSource.addFeatures(rest);
      if (sound.length) soundSource.addFeatures(sound);
      // Update the legend's unit label from whatever the chart provides
      const unit = fc.features[0].properties?.unit || 'METERS';
      const legendTitle = document.querySelector('#depth-legend .dl-title');
      if (legendTitle) legendTitle.textContent = `DEPTHS — ${unit}`;
    }
    _lastDepthBbox = [W, S, E, N];
    console.log(`[ENC] Depths loaded: ${fc.features?.length || 0} in viewport`);
  } catch (e) { console.warn('Depth load error', e); }
};

// Debounced reload on pan/zoom — fires ~300 ms after the user stops moving.
let _depthReloadTimer = null;
function _scheduleDepthReload() {
  clearTimeout(_depthReloadTimer);
  _depthReloadTimer = setTimeout(() => window.loadChartDepths(), 300);
}

window.loadChartLand = async function() {
  landSource.clear();
  try {
    const r = await fetch('/charts/land');
    const fc = await r.json();
    if (!fc.features?.length) return;
    landSource.addFeatures(
      new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' }));
    console.log(`[ENC] Land: ${fc.features.length} features`);
  } catch(e) { console.warn('Land load error', e); }
};

window.loadChartHazards = async function() {
  hazardSource.clear();
  try {
    const r = await fetch('/charts/hazards');
    const fc = await r.json();
    if (!fc.features?.length) return;
    hazardSource.addFeatures(
      new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' }));
    console.log(`[ENC] Hazards: ${fc.features.length} features`);
  } catch(e) { console.warn('Hazard load error', e); }
};

window.loadChartZones = async function() {
  zoneSource.clear();
  try {
    const r = await fetch('/charts/zones');
    const fc = await r.json();
    if (!fc.features?.length) return;
    zoneSource.addFeatures(
      new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' }));
    console.log(`[ENC] Zones: ${fc.features.length} features`);
  } catch(e) { console.warn('Zones load error', e); }
};

window.reloadAllChartLayers = function() {
  // Clear symbol cache so newly-built charts pick up fresh symbols
  Object.keys(_iconCache).forEach(k => delete _iconCache[k]);
  window.loadChartFeatures?.();
  window.loadChartLand?.();
  window.loadChartHazards?.();
  window.loadChartZones?.();
  if (depthLayer.getVisible()) {
    _lastDepthBbox = null;
    window.loadChartDepths?.();
  }
};

window.loadChartFeatures = async function() {
  encSource.clear();
  try {
    const r = await fetch('/charts/features');
    const fc = await r.json();
    if (!fc.features?.length) return;

    // ── Merge LIGHTS into nearby structure features ────────────────────────
    // S-57 encodes each buoy/beacon and its light as TWO separate objects.
    // They may be at exactly the same coordinates (NOAA pattern) or up to
    // ~50 m apart (≈0.00045°). We try exact match first, then proximity.
    // After merging: light attrs copied into host, LIGHT_POINT dropped.
    const MERGE_DEG = 0.00045;   // ~50 m — matches backend proximity radius
    const structList = [];       // [{ f, x, y }] — all structure features
    for (const f of fc.features) {
      const lyr = (f.properties?.layer || '').toUpperCase();
      if (lyr.startsWith('BOY') || lyr.startsWith('BCN') || lyr === 'LNDMRK') {
        const [x, y] = f.geometry.coordinates;
        structList.push({ f, x, y });
      }
    }
    // Fast exact-coord lookup (grid key rounded to 6 dp ≈ 0.11 m)
    const structByKey = new Map();
    for (const s of structList) {
      structByKey.set(`${s.x.toFixed(6)}_${s.y.toFixed(6)}`, s.f);
    }

    const before = fc.features.length;
    fc.features = fc.features.filter(f => {
      if (f.properties?.aid_type !== 'LIGHT_POINT') return true;
      const [lx, ly] = f.geometry.coordinates;

      // 1) Try exact coordinate match
      let host = structByKey.get(`${lx.toFixed(6)}_${ly.toFixed(6)}`);

      // 2) Proximity fallback (~50 m radius)
      if (!host) {
        let bestDist = MERGE_DEG;
        for (const s of structList) {
          const d = Math.sqrt((lx - s.x) ** 2 + (ly - s.y) ** 2);
          if (d < bestDist) { bestDist = d; host = s.f; }
        }
      }

      if (!host) return true;   // standalone light with no nearby structure — keep

      const hp = host.properties;
      const lp = f.properties;

      // Backfill colour from light if host has none
      if (!hp.colours?.length && lp.colours?.length) {
        hp.colours = lp.colours.slice();
        // Infer CATLAM from colour when missing
        const region = hp.cell_region || 'B';
        const ialaB  = region === 'B';
        const c0 = lp.colours[0];
        if (!hp.catlam) {
          if (c0 === 4) hp.catlam = ialaB ? 1 : 2;
          else if (c0 === 3) hp.catlam = ialaB ? 2 : 1;
        }
        if (hp.catlam === 1) hp.aid_type = 'LATERAL_PORT';
        else if (hp.catlam === 2) hp.aid_type = 'LATERAL_STBD';
      }
      // Copy light character, range, height → these appear in the tooltip
      if (lp.light_desc && !hp.light_desc) hp.light_desc = lp.light_desc;
      if (lp.range_nm   && !hp.range_nm)   hp.range_nm   = lp.range_nm;
      if (lp.height_m   && !hp.height_m)   hp.height_m   = lp.height_m;
      // Copy orient so the buoy/beacon can anchor its leading line correctly
      if (lp.orient != null && hp.orient == null) hp.orient = lp.orient;
      return false;   // drop the now-redundant LIGHT_POINT
    });
    const dropped = before - fc.features.length;

    // ── Merge LDLINE pairs (enfilaciones) ──────────────────────────────────
    // Enfilaciones always work in PAIRS (or triples): two aligned marks on the
    // SAME bearing define one leading line. We get one LDLINE per mark but must
    // show only ONE continuous line projected from the outermost (seaward) mark.
    // Group by rounded bearing (0.5° resolution) and cell, then keep the one
    // line that spans from the outermost start to the farthest endpoint.
    const ldByKey = new Map();  // `${cell}_${roundedBearing}` → [feature, ...]
    for (const f of fc.features) {
      if (f.properties?.layer !== 'LDLINE') continue;
      const bear = Math.round((f.properties.orient || 0) * 2) / 2;
      const cell = f.properties.cell || '';
      const key  = `${cell}_${bear}`;
      if (!ldByKey.has(key)) ldByKey.set(key, []);
      ldByKey.get(key).push(f);
    }
    const ldRemove = new Set();
    for (const [, group] of ldByKey) {
      if (group.length <= 1) continue;
      // Project each mark's start-point onto the bearing direction vector.
      // The mark with SMALLEST projection is most seaward (outermost, front mark).
      const bear0 = (group[0].properties.orient || 0) * Math.PI / 180;
      const dx = Math.sin(bear0), dy = Math.cos(bear0);
      const proj = group.map(f => {
        const [x, y] = f.geometry.coordinates[0];
        return { f, p: x * dx + y * dy, end: f.geometry.coordinates[1] };
      });
      proj.sort((a, b) => a.p - b.p);
      // Keep the outermost (front mark), extend its endpoint to the farthest inland end
      const keeper = proj[0].f;
      // Farthest inland endpoint (highest projection value)
      keeper.geometry.coordinates[1] = proj[proj.length - 1].end;
      // Build the name from all paired marks combined
      const names = [...new Set(proj.map(x => x.f.properties.name).filter(Boolean))];
      if (names.length > 1) keeper.properties.name = names.join(' — ');
      // Drop all others
      for (const { f } of proj.slice(1)) ldRemove.add(f);
    }
    fc.features = fc.features.filter(f => !ldRemove.has(f));

    const layerCounts = {};
    fc.features.forEach(f => {
      const l = f.properties?.layer || 'unknown';
      layerCounts[l] = (layerCounts[l] || 0) + 1;
    });
    console.log(`[ENC] Features by layer (${dropped} duplicate lights dropped):`, layerCounts);

    const fmt = new ol.format.GeoJSON();
    const features = fmt.readFeatures(fc, { featureProjection: 'EPSG:3857' });
    encSource.addFeatures(features);
  } catch(e) { console.warn('ENC load error', e); }
};
window.loadChartFeatures();
window.loadChartLand();
window.loadChartHazards();
window.loadChartZones();


const map = new ol.Map({
  target: 'map',
  layers: [osmLayer, oceanRefLayer, depthLayer, landLayer, zoneLayer, seaMapLayer, encLayer, hazardLayer, soundLayer, vesselsLayer, aidsLayer, aisGhostLayer],
  view: new ol.View({
    center: ol.proj.fromLonLat([MAP_CENTER_LON, MAP_CENTER_LAT]),
    zoom: MAP_ZOOM,
    minZoom: 5,
  }),
  controls: ol.control.defaults.defaults({ attribution: false }),
});

// ── Estilos ────────────────────────────────────────────────────────────────

// ── Geo helpers ───────────────────────────────────────────────────────────

// Compact nautical lat/lon  e.g.  10°31.2'N  74°48.3'W
function _fmtLL(lat, lon) {
  const ld = Math.floor(Math.abs(lat)), lm = (Math.abs(lat) - ld) * 60;
  const od = Math.floor(Math.abs(lon)), om = (Math.abs(lon) - od) * 60;
  return `${ld}°${lm.toFixed(1)}'${lat >= 0 ? 'N' : 'S'}  ${od}°${om.toFixed(1)}'${lon >= 0 ? 'E' : 'W'}`;
}

// CPA / TCPA of a moving vessel vs a stationary point (AtoN or own position).
// Returns { cpaNM, tcpaMin } or null if vessel is not moving or CPA is behind.
function _computeCPA(vesLat, vesLon, sogKt, cogDeg, refLat, refLon) {
  if (!sogKt || sogKt < 0.1) return null;
  const mPDLat = 111320;
  const mPDLon = 111320 * Math.cos(vesLat * Math.PI / 180);
  const dx = (vesLon - refLon) * mPDLon;   // metres east
  const dy = (vesLat - refLat) * mPDLat;   // metres north
  const spd = sogKt * 0.514444;             // m/s
  const h   = cogDeg * Math.PI / 180;
  const vx  = spd * Math.sin(h);
  const vy  = spd * Math.cos(h);
  const v2  = vx * vx + vy * vy;
  const tcpaSec = -(dx * vx + dy * vy) / v2;
  const cpax = dx + tcpaSec * vx;
  const cpay = dy + tcpaSec * vy;
  return {
    cpaNM:   Math.sqrt(cpax * cpax + cpay * cpay) / 1852,
    tcpaMin: tcpaSec / 60,
  };
}

// ── Estimated dimensions by AIS ship type — used when Type 5/24 hasn't arrived yet.
// Values are representative midpoints for each class (L×B in metres).
function _estimateDims(tipo) {
  if (tipo >= 80 && tipo < 90) return { length: 180, beam: 28 }; // tanker
  if (tipo >= 70 && tipo < 80) return { length: 150, beam: 22 }; // cargo
  if (tipo >= 60 && tipo < 70) return { length: 120, beam: 20 }; // passenger
  if (tipo >= 40 && tipo < 50) return { length:  40, beam: 10 }; // HSC
  if (tipo === 52 || tipo === 21) return { length: 35, beam: 10 };// tug
  if (tipo === 30)             return { length:  25, beam:  8 }; // fishing
  if (tipo === 36 || tipo === 37) return { length: 15, beam:  5 };// sailing
  return { length: 60, beam: 12 };                                // default
}

// Color escala por LOA (matching AR ECDIS) — la longitud es el dato útil
// para evitar colisiones; el tipo AIS es categoría administrativa.
function vesselColorByLength(L) {
  if (L >= 300) return '#ff3333';   // VLCC, ULCS
  if (L >= 200) return '#ff8800';   // large container/cruise
  if (L >= 100) return '#ffd700';   // medium
  if (L >=  50) return '#3399ff';   // coastal
  if (L >=  20) return '#99ddff';   // small craft
  return '#aaffaa';                  // yacht / fishing
}

// Compute ship silhouette polygon as ring of [lon,lat] coords.
// Reference point (AIS antenna) at the given (lat,lon).
// Bow points along heading (clockwise degrees from true north).
function _computeShipPolygon(lat, lon, headingDeg, length, beam,
                              toBow, toStern, toPort, toStarboard) {
  const L  = length || 50;
  const B  = beam   || L * 0.15;
  const Tb  = toBow       ?? L / 2;
  const Ts  = toStern     ?? (L - Tb);
  const Tp  = toPort      ?? B / 2;
  const Tsb = toStarboard ?? (B - Tp);

  // Ship-local frame (m): x=starboard, y=bow. Origin = antenna.
  // Pointed bow, parallel sides, square stern with small notch.
  const local = [
    [0,           Tb],            // bow tip
    [Tsb,         Tb * 0.65],     // starboard shoulder
    [Tsb,        -Ts * 0.50],     // mid-starboard
    [Tsb * 0.7,  -Ts],            // stern starboard corner
    [0,          -Ts * 0.92],     // stern notch
    [-Tp * 0.7,  -Ts],            // stern port corner
    [-Tp,        -Ts * 0.50],     // mid-port
    [-Tp,         Tb * 0.65],     // port shoulder
    [0,           Tb],            // close
  ];

  const h   = headingDeg * Math.PI / 180;
  const cos = Math.cos(h), sin = Math.sin(h);
  const mPerDegLat = 111320;
  const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180);

  return local.map(([x, y]) => {
    // Rotate clockwise from north: east =  x*cos + y*sin,  north = -x*sin + y*cos
    const e =  x * cos + y * sin;
    const n = -x * sin + y * cos;
    return [lon + e / mPerDegLon, lat + n / mPerDegLat];
  });
}

function vesselStyle(feature, resolution) {
  const heading = feature.get('heading') ?? feature.get('cog') ?? 0;
  const length  = feature.get('length') || 0;
  const tipo    = feature.get('tipo') || 0;
  const color   = length > 0 ? vesselColorByLength(length) : vesselColor(tipo);
  const geom    = feature.getGeometry();
  const isPoly  = geom && geom.getType() === 'Polygon';

  const name   = feature.get('nombre') || String(feature.get('mmsi') || '');
  const sogV   = feature.get('sog');
  const cogV   = feature.get('cog');
  const latV   = feature.get('lat');
  const lonV   = feature.get('lon');
  const tipNom = feature.get('tipo_nombre') || shipTypeName(feature.get('tipo') || 0);
  const cpaNM  = feature.get('cpa_nm');
  const tcpaM  = feature.get('tcpa_min');

  // Multi-line label — richer content at closer zoom (res < 100 m/px ≈ zoom 12)
  const detailed = resolution != null && resolution < 100;
  let labelText = name;
  const mmsiV    = feature.get('mmsi');
  const banderaV = feature.get('bandera');
  const flagV    = banderaV ? flagEmoji(banderaV) : '';
  if (detailed) {
    // L1: nombre  [MMSI]
    const l1 = mmsiV ? `${name}  [${mmsiV}]` : name;
    // L2: bandera (si está disponible)
    const l2 = flagV ? `${flagV}  ${banderaV}` : '';
    // L3: tipo · SOG · COG
    const l3 = `${tipNom}  ${sogV != null ? sogV.toFixed(1)+'kn' : '--'}  ${cogV != null ? Math.round(cogV)+'°' : '--'}`;
    // L4: posición náutica
    const l4 = (latV != null && lonV != null) ? _fmtLL(latV, lonV) : '';
    // L5: CPA/TCPA (solo si se acerca)
    const l5 = (cpaNM != null && tcpaM != null && tcpaM > 0)
      ? `CPA ${cpaNM.toFixed(2)}NM  TCPA ${tcpaM.toFixed(0)}min`
      : '';
    labelText = [l1, l2, l3, l4, l5].filter(Boolean).join('\n');
  } else {
    labelText = (sogV > 0.3 && cogV != null)
      ? `${name}\n${sogV.toFixed(1)}kn  ${Math.round(cogV)}°`
      : name;
  }
  const styles = [];

  if (isPoly) {
    // Pixel size of silhouette at current resolution — decides whether to
    // add a fallback icon for very-zoomed-out views.
    const ext = geom.getExtent();
    const sizePx = Math.max(ext[2] - ext[0], ext[3] - ext[1]) / resolution;

    styles.push(new ol.style.Style({
      fill:   new ol.style.Fill({ color }),
      stroke: new ol.style.Stroke({ color: '#000', width: 1 }),
    }));

    // COG vector — dashed line ahead of the bow (1 minute of travel)
    const sog = feature.get('sog') || 0;
    if (sog > 0.5) {
      const cog = feature.get('cog') ?? heading;
      const lat = feature.get('lat'), lon = feature.get('lon');
      const distM = sog * 0.514444 * 60;   // 1 minute @ SOG knots
      const h = cog * Math.PI / 180;
      const mPerDegLat = 111320;
      const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180);
      const tipLon = lon + (distM * Math.sin(h)) / mPerDegLon;
      const tipLat = lat + (distM * Math.cos(h)) / mPerDegLat;
      const fromCoord = ol.proj.fromLonLat([lon, lat]);
      const toCoord   = ol.proj.fromLonLat([tipLon, tipLat]);
      styles.push(new ol.style.Style({
        geometry: new ol.geom.LineString([fromCoord, toCoord]),
        stroke: new ol.style.Stroke({
          color, width: 1.5, lineDash: [4, 4],
        }),
      }));
    }

    // If the silhouette is too small to perceive, stamp the legacy ship icon
    // on the centroid so the vessel stays visible at world zoom.
    if (sizePx < 6) {
      const ctr = ol.extent.getCenter(ext);
      const canvas = _cachedIcon(`ship_${color}`, () => _drawShip(color));
      styles.push(new ol.style.Style({
        geometry: new ol.geom.Point(ctr),
        image: new ol.style.Icon({
          img: canvas, imgSize: [canvas.width, canvas.height],
          rotation: ol.math.toRadians(heading), rotateWithView: false,
        }),
      }));
    }

    styles.push(new ol.style.Style({
      geometry: new ol.geom.Point(ol.extent.getCenter(ext)),
      text: new ol.style.Text({
        text: labelText,
        offsetY: sizePx > 10 ? -(sizePx / 2) - 8 : 16,
        font: '500 10px Inter, sans-serif',
        fill:   new ol.style.Fill({ color: '#e0ecf8' }),
        stroke: new ol.style.Stroke({ color: '#030810', width: 3 }),
      }),
    }));

  } else {
    // Point feature — vessel reported position only, no dimensions yet.
    const canvas = _cachedIcon(`ship_${color}`, () => _drawShip(color));
    styles.push(new ol.style.Style({
      image: new ol.style.Icon({
        img: canvas, imgSize: [canvas.width, canvas.height],
        rotation: ol.math.toRadians(heading), rotateWithView: false,
      }),
      text: new ol.style.Text({
        text: labelText,
        offsetY: 20,
        font: '500 10px Inter, sans-serif',
        fill:   new ol.style.Fill({ color: '#e0ecf8' }),
        stroke: new ol.style.Stroke({ color: '#030810', width: 3 }),
      }),
    }));
  }
  return styles;
}

function vesselColor(tipo) {
  if (tipo >= 60 && tipo < 70) return '#06b6d4';  // pasajeros — cyan
  if (tipo >= 70 && tipo < 80) return '#f59e0b';  // cargo — amber
  if (tipo >= 80 && tipo < 90) return '#dc2626';  // tanquero — red
  if (tipo === 52 || tipo === 21) return '#f97316'; // remolcador — orange
  if (tipo === 30) return '#22c55e';               // pesquero — green
  return '#94a3b8';                                // default — slate
}

// ── Status dot: small alert overlay on top-right corner ─────────────────────
// Only painted when there's an anomaly so symbols stay clean in normal state.
// amber = fuera de posición  red = en movimiento / alarma
function _stampStatus(canvas, enPos, enMov) {
  if (!enMov && enPos !== false) return;   // normal state → leave symbol clean
  const ctx = canvas.getContext('2d');
  const r = 2.5;
  const x = canvas.width - r - 0.5, y = r + 0.5;
  const dotColor = enMov ? '#ff3333' : '#fbbf24';
  ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fillStyle = dotColor; ctx.fill();
  ctx.strokeStyle = '#000'; ctx.lineWidth = 0.8; ctx.stroke();
}

// ── Position dot: small black dot at bottom-center = geographic anchor point ──
function _posDot(canvas) {
  const ctx = canvas.getContext('2d');
  const cx = canvas.width / 2;
  ctx.beginPath(); ctx.arc(cx, canvas.height - 2, 2.5, 0, Math.PI * 2);
  ctx.fillStyle = '#111'; ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 0.8; ctx.stroke();
}

// Like _cachedIcon but always adds the position dot after drawing.
function _aidIcon(key, drawFn) {
  if (!_iconCache[key]) {
    const c = drawFn();
    _posDot(c);
    _iconCache[key] = c;
  }
  return _iconCache[key];
}

// ── Infer port/starboard from the aid's nombre ────────────────────────────────
function _isPortSide(nombre, ialaB) {
  const n = (nombre || '').toLowerCase();
  if (n.includes('babor') || n.includes('izquierda') || n.includes('larboard')) return true;
  if (n.includes('estribor') || n.includes('derecha') || n.includes('starboard')) return false;
  // Colour hints: IALA-B port=green, stbd=red; IALA-A inverted
  if (ialaB) {
    if (n.includes('verde') || n.includes('green')) return true;
    if (n.includes('rojo') || n.includes('red'))    return false;
  } else {
    if (n.includes('rojo') || n.includes('red'))    return true;
    if (n.includes('verde') || n.includes('green')) return false;
  }
  return true;  // default to port when ambiguous
}

// ── Cardinal quadrant from name ───────────────────────────────────────────────
function _cardinalQ(nombre) {
  const n = (nombre || '').toUpperCase();
  if (n.includes('SUR') || n.includes('SOUTH')) return 'S';
  if (n.includes('ESTE') || n.includes('EAST'))  return 'E';
  if (n.includes('OESTE') || n.includes('WEST') || n.includes('NW') || n.includes('SW')) {
    if (n.includes('NW') || n.includes('NOROESTE')) return 'NW';
    if (n.includes('SW') || n.includes('SUROESTE')) return 'SW';
    return 'W';
  }
  if (n.includes('NE') || n.includes('NORESTE')) return 'NE';
  if (n.includes('SE') || n.includes('SURESTE')) return 'SE';
  return 'N';
}

function aidStyle(feature) {
  const tipo   = (feature.get('tipo') || '').toUpperCase();
  const enPos  = feature.get('en_posicion') !== false;
  const enMov  = feature.get('en_movimiento') === true;
  const nombre = feature.get('nombre') || '';
  const ialaB  = window.IALA_REGION === 'B';

  // S-57 colour codes: 3=Red  4=Green
  const portCode = ialaB ? 4 : 3;
  const stbdCode = ialaB ? 3 : 4;
  const stat = enMov ? 'm' : enPos ? 'p' : 'o';

  let canvas;

  if (tipo === 'FARO') {
    // S-52 lighthouse symbol: building body + star + magenta flash
    canvas = _aidIcon(`faro_${stat}`, () => {
      const base = _ialaLight(1); // white light (code 1)
      _stampStatus(base, enPos, enMov); return base;
    });

  } else if (tipo === 'FAROL') {
    // Smaller lighthouse (farol de costa / sector light)
    canvas = _aidIcon(`farol_${stat}`, () => {
      const base = _ialaLight(1);
      _stampStatus(base, enPos, enMov); return base;
    });

  } else if (tipo === 'BOYA_LATERAL') {
    // Symbols ported from AR ECDIS — port=can, stbd=cone, colour by region.
    const isPort = _isPortSide(nombre, ialaB);
    const side   = isPort ? 'P' : 'S';
    canvas = _cachedIcon(`iala_lat_${side}_${ialaB}_${stat}`, () => {
      const c = isPort ? _ialaBoyLatPort(ialaB) : _ialaBoyLatStbd(ialaB);
      _stampStatus(c, enPos, enMov); return c;
    });

  } else if (tipo === 'BOYA_CARDINAL') {
    // Reduce intercardinals (NE/SE/NW/SW) to the 4 main quadrants.
    let q = _cardinalQ(nombre);
    if (!['N','S','E','W'].includes(q)) q = q[0];
    canvas = _cachedIcon(`iala_car_${q}_${stat}`, () => {
      const c = _ialaBoyCar(q);
      _stampStatus(c, enPos, enMov); return c;
    });

  } else if (tipo === 'BOYA_PELIGRO') {
    canvas = _cachedIcon(`iala_isd_${stat}`, () => {
      const c = _ialaBoyIsd();
      _stampStatus(c, enPos, enMov); return c;
    });

  } else if (tipo === 'BOYA_AGUAS_SEGURAS') {
    canvas = _cachedIcon(`iala_saw_${stat}`, () => {
      const c = _ialaBoySaw();
      _stampStatus(c, enPos, enMov); return c;
    });

  } else if (tipo === 'BOYA_ESPECIAL' || tipo.startsWith('BOYA')) {
    canvas = _cachedIcon(`iala_spp_${stat}`, () => {
      const c = _ialaBoySpp();
      _stampStatus(c, enPos, enMov); return c;
    });

  } else if (tipo === 'BALIZA') {
    const isPort = _isPortSide(nombre, ialaB);
    const catlam = isPort ? 1 : 2;
    const col    = isPort ? portCode : stbdCode;
    const side   = isPort ? 'P' : 'S';
    canvas = _aidIcon(`baliza_lat_${side}_${ialaB}_${stat}`, () => {
      const c = _encBeaconCanvas([col], catlam, ialaB ? 'B' : 'A');
      _stampStatus(c, enPos, enMov); return c;
    });

  } else if (tipo === 'ENFILACION') {
    canvas = _aidIcon(`enfil_${stat}`, () => {
      const c = _drawTriangle('#f9a825', '#c8780a', 22);
      _stampStatus(c, enPos, enMov); return c;
    });

  } else {
    const fill   = enMov ? '#e53935' : (enPos ? '#1db954' : '#f59e0b');
    const stroke = enMov ? '#ff6b6b' : (enPos ? '#4ade80' : '#fcd34d');
    canvas = _aidIcon(`aid_gen_${stat}`, () => _drawDiamond(fill, stroke));
  }

  // ── Aid label: nombre / tipo · pos actual / desplazamiento ─────────────
  const tipoDisp  = tipo.replace(/_/g, ' ');
  const latA  = feature.get('lat_actual')   ?? feature.get('lat_nominal');
  const lonA  = feature.get('lon_actual')   ?? feature.get('lon_nominal');
  const dispM = feature.get('desplazamiento_m') || 0;
  const posLine  = (latA != null && lonA != null) ? `${tipoDisp}  ${_fmtLL(latA, lonA)}` : tipoDisp;
  const dispLine = dispM > 2 ? `Despl: ${dispM.toFixed(0)} m` : '';
  const aidLabel = [nombre, posLine, dispLine].filter(Boolean).join('\n');

  return new ol.style.Style({
    image: new ol.style.Icon({
      img: canvas, imgSize: [canvas.width, canvas.height], scale: 1,
      anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
    }),
    // Sin label persistente: el tooltip de hover ya muestra nombre + características.
    // Solo se renderiza un indicador rojo si la boya tiene desplazamiento crítico.
    text: dispM > 15 ? new ol.style.Text({
      text: `Despl ${dispM.toFixed(0)} m`,
      offsetY: 14,
      font: 'bold 9px Inter, sans-serif',
      fill:   new ol.style.Fill({ color: '#fca5a5' }),
      stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.55)', width: 2 }),
    }) : undefined,
  });
}

// ── Actualizar features ────────────────────────────────────────────────────

window.updateVessel = function(data) {
  let feature = vesselsSource.getFeatureById(data.mmsi);
  const headingForGeom = data.heading ?? data.cog ?? 0;

  // Always draw a silhouette polygon. Use reported dimensions (Type 5/24) when
  // available; fall back to type-estimated dims so all targets show at real scale.
  const _dims = (data.length && data.beam)
    ? { length: data.length, beam: data.beam }
    : _estimateDims(data.tipo || 0);
  const ringLL = _computeShipPolygon(
    data.lat, data.lon, headingForGeom,
    _dims.length, _dims.beam,
    data.to_bow, data.to_stern, data.to_port, data.to_starboard
  );
  const geometry = new ol.geom.Polygon([ringLL.map(c => ol.proj.fromLonLat(c))]);

  if (!feature) {
    feature = new ol.Feature({ geometry });
    feature.setId(data.mmsi);
    vesselsSource.addFeature(feature);
  } else {
    feature.setGeometry(geometry);
  }

  // CPA/TCPA to nearest monitored AtoN ─────────────────────────────────────
  let bestCPA = null, bestAtonName = null, bestCPAdist = Infinity;
  aidsSource.forEachFeature(af => {
    const aLat = af.get('lat_actual') ?? af.get('lat_nominal');
    const aLon = af.get('lon_actual') ?? af.get('lon_nominal');
    if (aLat == null || aLon == null) return;
    const res = _computeCPA(data.lat, data.lon, data.sog || 0, data.cog || 0, aLat, aLon);
    if (res && res.cpaNM < bestCPAdist) {
      bestCPAdist = res.cpaNM;
      bestCPA     = res;
      bestAtonName = af.get('nombre') || af.get('id') || '';
    }
  });

  feature.setProperties({
    featureType: 'vessel',
    mmsi: data.mmsi, nombre: data.nombre,
    tipo: data.tipo, tipo_nombre: data.tipo_nombre,
    sog: data.sog, cog: data.cog, heading: data.heading,
    lat: data.lat, lon: data.lon,
    length: data.length, beam: data.beam,
    to_bow: data.to_bow, to_stern: data.to_stern,
    to_port: data.to_port, to_starboard: data.to_starboard,
    // Static AIS Type 5 / Type 24
    imo: data.imo, callsign: data.callsign, bandera: data.bandera,
    draught: data.draught, eta: data.eta,
    // Dynamic Type 1/2/3
    nav_status: data.nav_status, nav_status_name: data.nav_status_name,
    rot: data.rot, fix_type: data.fix_type,
    destino: data.destino, timestamp: data.timestamp,
    // CPA/TCPA to nearest AtoN
    cpa_nm:   bestCPA ? +bestCPA.cpaNM.toFixed(2)   : null,
    tcpa_min: bestCPA ? +bestCPA.tcpaMin.toFixed(1)  : null,
    cpa_aton: bestAtonName,
  });
  // Pass the function so OL re-evaluates per resolution (silhouette ↔ icon).
  feature.setStyle(vesselStyle);
  recordPosition(data.mmsi, data.lat, data.lon);
  _updateVector(data.mmsi, data.lat, data.lon, data.sog || 0, data.cog || 0);

  const n = vesselsSource.getFeatures().length;
  document.getElementById('vessel-count').textContent = n;
};

// Sync the AIS-reported ghost marker for an aid. Shown only when the AIS
// position differs from nominal by more than ~5 m (avoids ghost shimmer for
// boats sitting on the chain swing centre). Style is the same icon as the
// nominal marker but drawn with reduced opacity.
function _syncAisGhost(data, nominalFeature) {
  const ghostId = 'ghost_' + data.id;
  const existing = aisGhostSource.getFeatureById(ghostId);
  const la = data.lat_actual, lo = data.lon_actual;
  // Hide ghost when no AIS fix yet, or when fix is essentially on nominal.
  const HIDE_THRESHOLD_M = 5;
  const disp = data.desplazamiento_m;
  if (la == null || lo == null || (typeof disp === 'number' && disp < HIDE_THRESHOLD_M)) {
    if (existing) aisGhostSource.removeFeature(existing);
    return;
  }
  const coord = ol.proj.fromLonLat([lo, la]);
  let f = existing;
  if (!f) {
    f = new ol.Feature({ geometry: new ol.geom.Point(coord) });
    f.setId(ghostId);
    aisGhostSource.addFeature(f);
  } else {
    f.getGeometry().setCoordinates(coord);
  }
  // Copy aid props so aidStyle picks the right icon, then apply translucent
  // version of that style.
  f.setProperties({ ...data, featureType: 'aid', _ghost: true });
  const base = aidStyle(nominalFeature);
  const img  = base.getImage();
  const ghostStyle = new ol.style.Style({
    image: new ol.style.Icon({
      img:      img.getImage(),
      imgSize:  [img.getImage().width, img.getImage().height],
      scale:    img.getScale(),
      anchor:   [0.5, 1.0],
      opacity:  0.45,
    }),
  });
  f.setStyle(ghostStyle);
}

window.updateAid = function(data) {
  let feature = aidsSource.getFeatureById(data.id);
  // Main feature stays anchored at lat_nominal — "where the buoy should be"
  // (well-marked, opaque). The AIS-reported position rides on a separate
  // translucent ghost feature when it differs from nominal.
  // aid_position WS broadcasts only carry actual lat/lon (no nominal), so
  // fall back to the feature's existing nominal when nominal isn't in the
  // payload. If nothing is known yet, bail — we'll redraw on next init/event.
  const latNom = data.lat_nominal ?? feature?.get('lat_nominal');
  const lonNom = data.lon_nominal ?? feature?.get('lon_nominal');
  if (latNom == null || lonNom == null) return;
  const coord = ol.proj.fromLonLat([lonNom, latNom]);

  if (!feature) {
    feature = new ol.Feature({ geometry: new ol.geom.Point(coord) });
    feature.setId(data.id);
    aidsSource.addFeature(feature);
  } else {
    feature.getGeometry().setCoordinates(coord);
  }

  // Merge new fields without losing nominal coords from previous renders.
  const merged = { ...feature.getProperties(), ...data,
                   lat_nominal: latNom, lon_nominal: lonNom,
                   featureType: 'aid' };
  feature.setProperties(merged);
  feature.setStyle(aidStyle(feature));

  // ── Ghost AIS marker ────────────────────────────────────────────────────
  // Show a transparent overlay at lat_actual when AIS reported a position
  // that differs noticeably from nominal. Hidden when actual ≈ nominal so
  // the operator sees a single opaque marker on stable buoys.
  _syncAisGhost(merged, feature);

  // Limpiar símbolo solo si volvió a posición Y no hay alerta activa registrada
  if (data.en_posicion === true && !data.en_movimiento) {
    const id = data.id;
    const alerts = window.activeAlerts;
    const hasAlert = alerts &&
      (alerts.has(`ALERTA_ROJA__${id}`) || alerts.has(`ALERTA_AMARILLA__${id}`));
    if (!hasAlert) window.setAidWarning?.(feature, null);
  }

  const n = aidsSource.getFeatures().length;
  document.getElementById('aid-count').textContent = n;
};

// ── Tooltip ────────────────────────────────────────────────────────────────

const tooltip = document.getElementById('tooltip');

// Layers where hover should NOT pop a tooltip — land/depth areas cover huge
// regions and would fire on every mouse move over terrain.
// BUISGL (buildings) and point landmarks are intentionally NOT in this set.
const _NO_HOVER = new Set([
  'SOUNDG', 'DEPCNT', 'DEPARE',
  'LNDARE', 'LANDMASK', 'BUAARE', 'COALNE',   // land — no tooltip
  'LDLINE',                                     // leading lines — no tooltip (bearing shown as label)
]);

map.on('pointermove', (evt) => {
  // Skip depth features for hover detection. hitTolerance:8 so small
  // silhouettes (e.g. a 300m vessel renders ~4px wide at zoom 11) are still
  // hoverable without pixel-perfect aiming.
  const feature = map.forEachFeatureAtPixel(evt.pixel,
    f => _NO_HOVER.has(f.get('layer')) ? null : f,
    { hitTolerance: 8 });
  if (!feature) {
    tooltip.classList.add('hidden');
    map.getTargetElement().style.cursor = '';
    return;
  }

  const p = feature.getProperties();
  tooltip.classList.remove('hidden');
  tooltip.style.left = (evt.originalEvent.clientX + 14) + 'px';
  tooltip.style.top  = (evt.originalEvent.clientY - 10) + 'px';
  map.getTargetElement().style.cursor = 'pointer';

  if (p.featureType === 'vessel') {
    const dims = (p.length && p.beam) ? `${p.length} × ${p.beam} m` : '--';
    tooltip.innerHTML = `
      <div class="tt-type">AIS TARGET</div>
      <div class="tt-title">${p.nombre || 'UNKNOWN'}</div>
      <div class="tt-row"><span class="tt-key">MMSI</span><span class="tt-val">${p.mmsi}</span></div>
      ${p.callsign ? `<div class="tt-row"><span class="tt-key">CALL</span><span class="tt-val">${p.callsign}</span></div>` : ''}
      <div class="tt-row"><span class="tt-key">TYPE</span><span class="tt-val">${p.tipo_nombre || shipTypeName(p.tipo)}</span></div>
      <div class="tt-row"><span class="tt-key">SIZE</span><span class="tt-val">${dims}</span></div>
      <div class="tt-row"><span class="tt-key">SOG</span><span class="tt-val">${p.sog} kn</span></div>
      <div class="tt-row"><span class="tt-key">COG</span><span class="tt-val">${p.cog}°</span></div>
      ${p.draught != null ? `<div class="tt-row"><span class="tt-key">DRAUGHT</span><span class="tt-val">${p.draught} m</span></div>` : ''}
      ${p.nav_status_name ? `<div class="tt-row"><span class="tt-key">STATUS</span><span class="tt-val">${p.nav_status_name}</span></div>` : ''}
      <div class="tt-row"><span class="tt-key">DEST</span><span class="tt-val">${p.destino || '--'}</span></div>
    `;
  } else if (p.featureType === 'aid') {
    const disp   = p.desplazamiento_m ? `${p.desplazamiento_m.toFixed(1)} m` : '--';
    const estado = p.en_movimiento ? 'MOVING' : (p.en_posicion !== false ? 'ON POSITION' : 'DISPLACED');
    tooltip.innerHTML = `
      <div class="tt-type">AID TO NAVIGATION</div>
      <div class="tt-title">${p.nombre}</div>
      <div class="tt-row"><span class="tt-key">TYPE</span><span class="tt-val">${p.tipo}</span></div>
      <div class="tt-row"><span class="tt-key">AIS</span><span class="tt-val">${p.tipo_ais}</span></div>
      <div class="tt-row"><span class="tt-key">DISPL</span><span class="tt-val">${disp}</span></div>
      <div class="tt-row"><span class="tt-key">STATUS</span><span class="tt-val">${estado}</span></div>
    `;
  } else if (p.layer) {
    // S-57 ENC feature — skip bare land areas (buildings are allowed)
    if (['LNDARE','LANDMASK','BUAARE','COALNE'].includes(p.layer)) {
      tooltip.classList.add('hidden');
      return;
    }
    const src = p.cell ? `S-57 ENC (${p.cell})` : 'S-57 ENC';
    const aidType  = (p.aid_type || '').replace(/_/g, ' ');
    const colNames = { 1:'White', 2:'Black', 3:'Red', 4:'Green', 5:'Blue',
                       6:'Yellow', 7:'Grey', 8:'Brown', 9:'Amber', 10:'Violet',
                       11:'Orange', 12:'Magenta' };
    const colsTxt  = (p.colours || []).map(c => colNames[c] || c).join(' / ');
    // Building tooltip — only name + height, no type header
    if (p.layer === 'BUISGL') {
      tooltip.innerHTML = `
        <div class="tt-title">${p.name || 'Edificio'}</div>
        ${p.height_m ? `<div class="tt-row"><span class="tt-key">HEIGHT</span><span class="tt-val">${p.height_m} m</span></div>` : ''}
      `;
      return;
    }
    tooltip.innerHTML = `
      <div class="tt-type">${src}</div>
      <div class="tt-title">${p.name || p.layer}</div>
      ${aidType   ? `<div class="tt-row"><span class="tt-key">TYPE</span><span class="tt-val">${aidType}</span></div>` : ''}
      ${colsTxt   ? `<div class="tt-row"><span class="tt-key">COLOUR</span><span class="tt-val">${colsTxt}</span></div>` : ''}
      ${p.light_desc ? `<div class="tt-row"><span class="tt-key">LIGHT</span><span class="tt-val" style="color:#ffeb3b">${p.light_desc}</span></div>` : ''}
      ${p.range_nm  ? `<div class="tt-row"><span class="tt-key">RANGE</span><span class="tt-val">${p.range_nm} NM</span></div>` : ''}
      ${p.height_m  ? `<div class="tt-row"><span class="tt-key">HEIGHT</span><span class="tt-val">${p.height_m} m</span></div>` : ''}
      ${p.shape     ? `<div class="tt-row"><span class="tt-key">SHAPE</span><span class="tt-val">${p.shape}</span></div>` : ''}
      ${p.info      ? `<div class="tt-row"><span class="tt-key">INFO</span><span class="tt-val" style="font-size:0.75rem">${p.info}</span></div>` : ''}
      <div class="tt-row" style="margin-top:4px;border-top:1px solid #2a3f55;padding-top:4px">
        <span class="tt-key">AIS</span>
        <span class="tt-val" style="color:var(--text-muted);font-style:italic">no receiver</span>
      </div>
    `;
  }
});

// ── Click → panel info ─────────────────────────────────────────────────────

map.on('click', (evt) => {
  const feature = map.forEachFeatureAtPixel(evt.pixel, f => f, { hitTolerance: 8 });
  if (feature) showInfoPanel(feature.getProperties());
});

function showInfoPanel(p) {
  const panel = document.getElementById('info-content');

  if (p.featureType === 'vessel') {
    panel.innerHTML = renderVesselInfo(p);

  } else if (p.featureType === 'aid') {
    renderAidInfo(p, panel);
  } else if (p.layer) {
    renderEncInfo(p, panel);
  }
}

// ── Vessel info panel — full AIS data (Type 1/2/3 + 5 + 24) ───────────────
function renderVesselInfo(p) {
  const lat = p.lat, lon = p.lon;
  const posStr = (lat != null && lon != null) ? _fmtLL(lat, lon) : '--';
  const flagTxt = p.bandera ? `${p.bandera} ${flagEmoji(p.bandera)}` : '--';
  const navStatus = p.nav_status_name || (p.nav_status != null ? `Status ${p.nav_status}` : '--');
  const navColor = navStatusColor(p.nav_status);
  const fixTxt   = fixTypeName(p.fix_type);

  return `
    <div class="obj-type-tag tag-vessel">AIS VESSEL TARGET</div>
    <div class="obj-name">${p.nombre || 'UNKNOWN'}</div>
    <div class="obj-sub">MMSI ${p.mmsi}${p.callsign ? ' &mdash; ' + p.callsign : ''}</div>

    <div class="coords-block">
      <div class="label">POSITION</div>
      ${posStr}
    </div>

    <div class="field-row">
      <div class="field"><div class="field-label">SOG</div><div class="field-value">${fmtNum(p.sog, 1)} kn</div></div>
      <div class="field"><div class="field-label">COG</div><div class="field-value">${fmtNum(p.cog, 1)}°</div></div>
    </div>
    <div class="field-row">
      <div class="field"><div class="field-label">HDG</div><div class="field-value">${fmtNum(p.heading, 0)}°</div></div>
      <div class="field"><div class="field-label">ROT</div><div class="field-value">${p.rot != null ? fmtNum(p.rot, 1) + '°/min' : '--'}</div></div>
    </div>
    <div class="field" style="margin-top:6px">
      <div class="field-label">NAV STATUS</div>
      <div class="field-value" style="color:${navColor}">${navStatus}</div>
    </div>

    <div class="divider"></div>
    <div class="obj-type-tag" style="background:#1a3a4a;color:var(--accent)">STATIC DATA &mdash; AIS MSG 5</div>

    <div class="field-row">
      <div class="field"><div class="field-label">IMO</div><div class="field-value">${p.imo || '--'}</div></div>
      <div class="field"><div class="field-label">CALLSIGN</div><div class="field-value">${p.callsign || '--'}</div></div>
    </div>
    <div class="field-row">
      <div class="field"><div class="field-label">FLAG</div><div class="field-value">${flagTxt}</div></div>
      <div class="field"><div class="field-label">SHIP TYPE</div><div class="field-value">${p.tipo_nombre || shipTypeName(p.tipo)}</div></div>
    </div>
    <div class="field-row">
      <div class="field"><div class="field-label">LOA</div><div class="field-value">${p.length ? p.length + ' m' : '<span style="color:var(--text-muted);font-style:italic">est.</span>'}</div></div>
      <div class="field"><div class="field-label">BEAM</div><div class="field-value">${p.beam ? p.beam + ' m' : '<span style="color:var(--text-muted);font-style:italic">est.</span>'}</div></div>
    </div>
    ${p.to_bow != null ? `
    <div class="field-row">
      <div class="field"><div class="field-label">TO BOW</div><div class="field-value">${p.to_bow} m</div></div>
      <div class="field"><div class="field-label">TO STERN</div><div class="field-value">${p.to_stern} m</div></div>
    </div>
    <div class="field-row">
      <div class="field"><div class="field-label">TO PORT</div><div class="field-value">${p.to_port} m</div></div>
      <div class="field"><div class="field-label">TO STBD</div><div class="field-value">${p.to_starboard} m</div></div>
    </div>` : ''}
    <div class="field-row">
      <div class="field"><div class="field-label">DRAUGHT</div><div class="field-value">${p.draught != null ? p.draught + ' m' : '--'}</div></div>
      <div class="field"><div class="field-label">FIX TYPE</div><div class="field-value">${fixTxt}</div></div>
    </div>

    <div class="divider"></div>
    <div class="obj-type-tag" style="background:#0f2a20;color:#4ade80">CPA / TCPA</div>

    ${(p.cpa_nm != null && p.tcpa_min != null) ? `
    <div class="field-row">
      <div class="field">
        <div class="field-label">CPA</div>
        <div class="field-value" style="color:${p.cpa_nm < 0.3 ? '#f87171' : p.cpa_nm < 0.5 ? '#fbbf24' : '#4ade80'}">${p.cpa_nm.toFixed(2)} NM</div>
      </div>
      <div class="field">
        <div class="field-label">TCPA</div>
        <div class="field-value" style="color:${p.tcpa_min < 0 ? 'var(--text-muted)' : p.tcpa_min < 5 ? '#f87171' : '#4ade80'}">
          ${p.tcpa_min < 0 ? 'passed' : p.tcpa_min.toFixed(0) + ' min'}
        </div>
      </div>
    </div>
    ${p.cpa_aton ? `<div class="field"><div class="field-label">REF ATON</div><div class="field-value" style="font-size:0.75rem">${p.cpa_aton}</div></div>` : ''}
    ` : `<div style="font-size:0.72rem;color:var(--text-muted)">No AtoN in range or vessel not moving</div>`}

    <div class="divider"></div>
    <div class="obj-type-tag" style="background:#3a2a1a;color:#ffb74d">VOYAGE</div>

    <div class="field"><div class="field-label">DESTINATION</div><div class="field-value">${p.destino || '--'}</div></div>
    <div class="field" style="margin-top:6px"><div class="field-label">ETA</div><div class="field-value">${p.eta || '--'}</div></div>
    <div class="field" style="margin-top:6px"><div class="field-label">LAST SIGNAL</div><div class="field-value">${formatUTC(p.timestamp)}<br><span style="color:var(--text-muted);font-size:0.75rem">${_ageStr(p.timestamp)}</span></div></div>

    <div class="divider"></div>
    <button class="edit-btn" id="btn-track-vessel"
      onclick="toggleTrack('${p.mmsi}',${p.lat},${p.lon},${p.sog},${p.cog})">
      TRACK &amp; PROJECT HEADING
    </button>
    <button class="edit-btn" id="btn-rec-vessel" style="margin-top:6px;background:var(--red)"
      onclick="toggleManualRec('${p.mmsi}')">
      ⏺ START RECORDING
    </button>
    <div id="track-warnings" style="display:none;margin-top:8px"></div>
  `;
}

function fmtNum(v, d) { return v == null || isNaN(v) ? '--' : Number(v).toFixed(d); }

// MMSI MID → ISO-2 country code (subset of common flags). Best-effort.
function flagEmoji(code) {
  const map = {
    US:'🇺🇸', PA:'🇵🇦', BS:'🇧🇸', LR:'🇱🇷', MH:'🇲🇭', MT:'🇲🇹',
    CY:'🇨🇾', SG:'🇸🇬', HK:'🇭🇰', GR:'🇬🇷', NO:'🇳🇴', GB:'🇬🇧',
    NL:'🇳🇱', DE:'🇩🇪', FR:'🇫🇷', IT:'🇮🇹', ES:'🇪🇸', JP:'🇯🇵',
    KR:'🇰🇷', CN:'🇨🇳', BR:'🇧🇷', MX:'🇲🇽', CO:'🇨🇴', VE:'🇻🇪',
    AR:'🇦🇷', CL:'🇨🇱', PE:'🇵🇪', EC:'🇪🇨',
  };
  return map[code] || '';
}

// Color hint for nav status — green when steaming, amber moored/anchor, red restricted
function navStatusColor(s) {
  if (s == null) return 'var(--text)';
  if (s === 0 || s === 8) return '#4ade80';        // under way
  if (s === 1 || s === 5) return '#fbbf24';        // anchor / moored
  if (s === 6)            return '#dc2626';        // aground
  if (s === 7)            return '#06b6d4';        // fishing
  if (s >= 2 && s <= 4)   return '#fb923c';        // restricted
  if (s === 14)           return '#dc2626';        // SART/EPIRB distress
  return 'var(--text)';
}

function fixTypeName(t) {
  const m = {1:'GPS', 2:'GLONASS', 3:'GPS+GLONASS', 4:'Loran-C',
             5:'Chayka', 6:'INS', 7:'Surveyed', 8:'Galileo'};
  return m[t] || (t == null ? '--' : `Type ${t}`);
}

// Map S-57 layer codes to Aid.tipo enum (best effort; operator can refine).
function _s57LayerToAidTipo(layer, aidType) {
  const L = (layer || '').toUpperCase();
  if (L === 'BOYLAT') return 'BOYA_LATERAL';
  if (L === 'BOYCAR') return 'BOYA_CARDINAL';
  if (L === 'BOYISD') return 'BOYA_PELIGRO';
  if (L === 'BOYSAW') return 'BOYA_AGUAS_SEGURAS';
  if (L === 'BOYSPP') return 'BOYA_ESPECIAL';
  if (L.startsWith('BCN')) return 'BALIZA';
  if (L === 'LIGHTS') return 'FAROL';
  if (L === 'DAYMAR') return 'BALIZA';
  return 'BOYA_ESPECIAL';
}

// Stable fingerprint for a S-57 feature within a cell. LNAM would be ideal
// but the chart cache doesn't expose it, so we hash layer + position rounded
// to 6 decimals (~11 cm). Stable across reloads and minor ENC re-edits that
// don't move the feature.
function _chartFeatureId(layer, lon, lat) {
  return `${(layer || 'UNK').toUpperCase()}_${lat.toFixed(6)}_${lon.toFixed(6)}`;
}

async function renderEncInfo(p, panel) {
  const geom = p.geometry;
  const coord = geom ? ol.proj.toLonLat(geom.flatCoordinates || [0,0]) : null;
  const aidType  = (p.aid_type || '').replace(/_/g, ' ');
  const colNames = { 1:'White', 2:'Black', 3:'Red', 4:'Green', 5:'Blue',
                     6:'Yellow', 7:'Grey', 8:'Brown', 9:'Amber', 10:'Violet',
                     11:'Orange', 12:'Magenta' };
  const colsTxt  = (p.colours || []).map(c => colNames[c] || c).join(' / ');

  // Render S-57 panel synchronously (no flicker if /aids lookup is slow).
  const baseHtml = `
    <div class="obj-type-tag" style="background:#1a3a4a;color:var(--accent)">S-57 ENC CHART</div>
    <div class="obj-name">${p.name || p.layer}</div>
    <div class="obj-sub">${p.cell || ''} &mdash; ${p.layer}${p.cell_region ? ' &mdash; IALA-' + p.cell_region : ''}</div>
    ${coord ? `<div class="coords-block">
      <div class="label">OFFICIAL POSITION (S-57)</div>
      ${coord[1].toFixed(5)} ${coord[1] >= 0 ? 'N' : 'S'} &nbsp;
      ${Math.abs(coord[0]).toFixed(5)} ${coord[0] >= 0 ? 'E' : 'W'}
    </div>` : ''}
    <div class="divider"></div>
    ${aidType ? `<div class="field"><div class="field-label">TYPE</div><div class="field-value">${aidType}</div></div>` : ''}
    ${colsTxt ? `<div class="field"><div class="field-label">COLOUR</div><div class="field-value">${colsTxt}</div></div>` : ''}
    ${p.light_desc ? `<div class="field-row">
      <div class="field"><div class="field-label">LIGHT</div><div class="field-value" style="color:#ffeb3b">${p.light_desc}</div></div>
    </div>` : ''}
    ${p.range_nm ? `<div class="field-row">
      <div class="field"><div class="field-label">RANGE</div><div class="field-value">${p.range_nm} NM</div></div>
      ${p.height_m ? `<div class="field"><div class="field-label">HEIGHT</div><div class="field-value">${p.height_m} m</div></div>` : ''}
    </div>` : ''}
    ${p.shape ? `<div class="field"><div class="field-label">SHAPE</div><div class="field-value">${p.shape}</div></div>` : ''}
    ${p.info  ? `<div class="field"><div class="field-label">INFO</div><div class="field-value" style="font-size:0.7rem">${p.info}</div></div>` : ''}
    <div class="divider"></div>
    <div class="obj-type-tag" style="background:#2a1a3a;color:#b39ddb;margin-top:4px">AIS DATA</div>
    <div class="field"><div class="field-value" style="color:var(--text-muted);font-style:italic;font-size:0.75rem">
      Configure as monitored aid below to link an AIS MMSI and start receiving Type 21 updates.
    </div></div>
    <div class="divider"></div>
    <div class="field"><div class="field-label">SOURCE</div><div class="field-value" style="color:var(--accent)">S-57 OFFICIAL CHART</div></div>
    <div id="enc-aid-link-slot" style="margin-top:10px"></div>
  `;
  panel.innerHTML = baseHtml;

  // Cannot link without cell + position. Bail silently.
  if (!coord || !p.cell) return;
  const [lon, lat] = coord;
  const cellId    = p.cell;
  const featureId = _chartFeatureId(p.layer, lon, lat);

  // Render the SAVE button up-front — but ONLY for superadmins. Promoting a
  // S-57 chart feature to a monitored Aid is a config-level action that
  // creates an immutable UUID and should not be available to regular admins
  // or unauthenticated viewers. The async lookup below still runs (so an
  // existing aid still upgrades the panel) regardless of role.
  const slot = panel.querySelector('#enc-aid-link-slot');
  const isSuper = !!window.Auth?.isSuperAdmin?.();
  if (slot && isSuper) {
    slot.innerHTML = `
      <button class="edit-btn" id="btn-promote-s57"
              style="width:100%;background:#1e6f3a;border-color:#2a9050;color:#fff">
        GUARDAR COMO AYUDA MONITOREADA
      </button>
      <div style="font-size:0.65rem;color:var(--text-muted);margin-top:6px;text-align:center">
        Crea un registro editable enlazado a este feature de la carta.
        Luego podrás asignar MMSI / lámpara / responsable.
      </div>
    `;
  }

  let existingAid = null;
  try {
    const r = await fetch(`/aids/by-chart-feature?cell_id=${encodeURIComponent(cellId)}&feature_id=${encodeURIComponent(featureId)}`);
    if (r.ok) existingAid = await r.json();
    else if (r.status !== 404) throw new Error(`Lookup failed (${r.status})`);
  } catch (e) {
    console.warn('[enc] Aid lookup error:', e);
    // Keep the SAVE button visible — user can still try to create.
    return;
  }

  if (existingAid) {
    // An Aid already tracks this S-57 feature — replace the panel with the
    // full Aid info so the operator sees MMSI / lamp / alerts / EDIT button.
    if (window.renderAidInfo) {
      window.renderAidInfo({ ...existingAid, featureType: 'aid' }, panel);
    }
    return;
  }

  // Wire the button click handler (button HTML already rendered above).
  document.getElementById('btn-promote-s57')?.addEventListener('click', async () => {
    const btn = document.getElementById('btn-promote-s57');
    if (!window.Auth?.isAdmin?.()) {
      window.Modal?.openLogin?.(null);
      return;
    }
    btn.disabled = true;
    btn.textContent = 'GUARDANDO...';
    try {
      const r = await fetch('/aids/from-chart-feature', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${window.Auth.token()}`,
        },
        body: JSON.stringify({
          cell_id:          cellId,
          chart_feature_id: featureId,
          lat_nominal:      lat,
          lon_nominal:      lon,
          nombre:           p.name || `${p.layer} ${featureId}`,
          tipo:             _s57LayerToAidTipo(p.layer, p.aid_type),
          categoria:        'FLOTANTE',
          radio_borneo_m:   10.0,
        }),
      });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || `HTTP ${r.status}`);
      const newAid = await r.json();
      // Add the new feature to the aids layer immediately
      window.updateAid?.(newAid);
      // Re-render panel as a full Aid panel (with EDIT AID DATA button)
      window.renderAidInfo?.({ ...newAid, featureType: 'aid' }, panel);
    } catch (e) {
      btn.disabled = false;
      btn.textContent = 'GUARDAR COMO AYUDA MONITOREADA';
      alert('No se pudo crear la ayuda: ' + e.message);
    }
  });
}

// ── Lamp assignment from the right panel ──────────────────────────────────
window.assignLamp = async function(aidId, lampId) {
  try {
    const r = await fetch(`/aids/${aidId}/lamp`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ lamp_id: lampId || null }),
    });
    if (!r.ok) throw new Error((await r.json()).detail || 'Failed');
    const updated = await r.json();
    // Re-render the panel with fresh data so the warn/alarm row appears
    renderAidInfo(updated, document.getElementById('info-content'));
  } catch (e) {
    alert('Could not assign lamp: ' + e.message);
  }
};

// ── Manual recording toggle ───────────────────────────────────────────────
const _manualRecs = new Set();   // mmsis currently being manually recorded

window.toggleManualRec = async function(mmsi) {
  const btn = document.getElementById('btn-rec-vessel');
  const isRec = _manualRecs.has(mmsi);
  try {
    const url = isRec
      ? `/recordings/stop/${mmsi}?aid_id=MANUAL`
      : `/recordings/start/${mmsi}?aid_id=MANUAL`;
    const r = await fetch(url, {
      method: 'POST',
      headers: { Authorization: `Bearer ${window.Auth?.token()}` },
    });
    if (!r.ok) throw new Error((await r.json()).detail);
    if (isRec) {
      _manualRecs.delete(mmsi);
      if (btn) { btn.textContent = '⏺ START RECORDING'; btn.style.background = 'var(--red)'; }
    } else {
      _manualRecs.add(mmsi);
      if (btn) { btn.textContent = '⏹ STOP RECORDING'; btn.style.background = '#7c2d12'; }
    }
  } catch(e) {
    alert('Recording error: ' + e.message);
  }
};

window.refreshInfoPanel = function(p) {
  renderAidInfo(p, document.getElementById('info-content'));
};

function renderAidInfo(p, panel) {
  panel.dataset.mmsi = p.mmsi || '';
  const disp      = p.desplazamiento_m || 0;
  const dispPct   = Math.min(100, (disp / 20) * 100);
  const dispColor = disp > 15 ? '#e53935' : disp > 8 ? '#f59e0b' : '#1db954';
  const pillClass = p.en_movimiento ? 'pill-danger' : (p.en_posicion !== false ? 'pill-ok' : 'pill-warn');
  const dotClass  = p.en_movimiento ? 'dot-danger'  : (p.en_posicion !== false ? 'dot-ok'  : 'dot-warn');
  const statusTxt = p.en_movimiento ? 'CONTINUOUS MOVEMENT' : (p.en_posicion !== false ? 'ON POSITION' : 'DISPLACED');

  const posActual = (p.lat_actual && p.lon_actual) ? `
    <div class="coords-block">
      <div class="label">CURRENT POSITION (AIS)</div>
      ${p.lat_actual.toFixed(5)} N &nbsp; ${p.lon_actual.toFixed(5)} W
    </div>
    <div class="field" style="margin-bottom:10px">
      <div class="field-label">DISPLACEMENT &mdash; ${disp.toFixed(1)} m</div>
      <div class="disp-bar-wrap">
        <div class="disp-bar" style="width:${dispPct}%;background:${dispColor}"></div>
      </div>
    </div>` : '';

  // Lamp section — assignment + computed battery thresholds
  const lamps = window._lampCache || [];
  const currentLamp = lamps.find(l => l.id === p.lamp_id);
  const lampOptions = `
    <option value="">— No lamp assigned —</option>
    ${lamps.map(l => `<option value="${l.id}" ${l.id === p.lamp_id ? 'selected' : ''}>
      ${l.manufacturer} ${l.model} (${l.voltage_min}–${l.voltage_max} V)
    </option>`).join('')}`;
  const lampBlock = `
    <div class="divider"></div>
    <div class="field-label" style="margin-bottom:4px">LAMP &amp; BATTERY THRESHOLDS</div>
    <select class="form-input-select" style="width:100%;font-size:0.72rem"
            onchange="assignLamp('${p.id}', this.value)">${lampOptions}</select>
    ${currentLamp ? `
      <div class="field-row" style="margin-top:6px">
        <div class="field"><div class="field-label">V min</div><div class="field-value">${currentLamp.voltage_min} V</div></div>
        <div class="field"><div class="field-label">V max</div><div class="field-value">${currentLamp.voltage_max} V</div></div>
        <div class="field"><div class="field-label" style="color:var(--yellow)">WARN</div><div class="field-value" style="color:var(--yellow)">${currentLamp.warn_v} V</div></div>
        <div class="field"><div class="field-label" style="color:var(--red)">ALARM</div><div class="field-value" style="color:var(--red)">${currentLamp.alarm_v} V</div></div>
      </div>` : `
      <div style="font-size:0.7rem;color:var(--yellow);margin-top:4px">
        ⚠ No lamp configured — battery alerts use global defaults from Settings.
      </div>`}
  `;

  // Pull live ATON telemetry if available
  const aton = window.atonData?.[p.mmsi] || {};
  const hasAton = Object.keys(aton).length > 0;

  // ATON block: SIEMPRE se renderiza para que TODA ayuda pueda configurarse
  // como referencia (luz, alcance, telemetría esperada).
  // Si hay live data → se muestra; sino → "Waiting for live telemetry…".
  const atonTelem = hasAton ? `
    <div class="aton-telemetry">
      <div class="aton-telem-title">LIVE TELEMETRY <span class="aton-ts">${aton.last_update ? aton.last_update.slice(11,19)+' UTC' : ''}</span></div>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('voltage',this.checked)">
        <span class="aton-key">BATTERY</span>
        <span class="aton-val ${aton.battery_low ? 'aton-warn' : ''}" id="af-voltage">
          ${aton.voltage_v != null ? aton.voltage_v+' V'+(aton.battery_low?' ⚠ LOW':'') : 'N/A'}
        </span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('sensor1',this.checked)">
        <span class="aton-key">SENSOR 1</span>
        <span class="aton-val" id="af-sensor1">${aton.sensor1 != null ? aton.sensor1 : 'N/A'}</span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('sensor2',this.checked)">
        <span class="aton-key">SENSOR 2</span>
        <span class="aton-val" id="af-sensor2">${aton.sensor2 != null ? aton.sensor2 : 'N/A'}</span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('sensor3',this.checked)">
        <span class="aton-key">SENSOR 3</span>
        <span class="aton-val" id="af-sensor3">${aton.sensor3 != null ? aton.sensor3 : 'N/A'}</span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('light',this.checked)">
        <span class="aton-key">LIGHT STATUS</span>
        <span class="aton-val ${aton.light_ok===false ? 'aton-warn' : 'aton-ok'}" id="af-light">
          ${aton.light_ok != null ? (aton.light_ok ? '✓ OK' : '✗ FAULT') : 'N/A'}
        </span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('racon',this.checked)">
        <span class="aton-key">RACON</span>
        <span class="aton-val" id="af-racon">${aton.racon_ok != null ? (aton.racon_ok ? '✓ OK' : '✗ FAULT') : 'N/A'}</span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('offpos',this.checked)">
        <span class="aton-key">OFF POSITION</span>
        <span class="aton-val ${aton.off_position ? 'aton-warn' : 'aton-ok'}" id="af-offpos">
          ${aton.off_position != null ? (aton.off_position ? '⚠ YES' : 'NO') : 'N/A'}
        </span>
      </label>

      <label class="aton-check"><input type="checkbox" checked onchange="toggleAtonField('health',this.checked)">
        <span class="aton-key">HEALTH</span>
        <span class="aton-val ${aton.health > 1 ? 'aton-warn' : 'aton-ok'}" id="af-health">
          ${['OK','WARNING','ALARM','NO SIGNAL'][aton.health ?? 0]}
        </span>
      </label>

      ${aton.virtual ? '<div class="aton-virtual">VIRTUAL ATON</div>' : ''}
    </div>` : '<div style="font-size:0.68rem;color:var(--text-muted);margin-top:6px">Waiting for live telemetry…</div>';

  const atonBlock = `
    <div class="divider"></div>
    <div class="aton-badge">ATON TRANSPONDER</div>

    <div class="field-row">
      <div class="field"><div class="field-label">LIGHT</div><div class="field-value" style="color:#ffeb3b">${p.caracteristica_luz || '--'}</div></div>
      <div class="field"><div class="field-label">RANGE</div><div class="field-value">${p.alcance_nm != null ? p.alcance_nm + ' NM' : '--'}</div></div>
    </div>

    ${atonTelem}
  `;

  panel.innerHTML = `
    <div class="obj-type-tag tag-aid">AID TO NAVIGATION</div>
    <div class="obj-name">${p.nombre}</div>
    <div class="obj-sub">${p.tipo} &mdash; ${p.categoria}</div>

    <div class="status-pill ${pillClass}">
      <span class="status-dot-small ${dotClass}"></span>
      ${statusTxt}
    </div>

    <div class="coords-block">
      <div class="label">NOMINAL POSITION (OFFICIAL)</div>
      ${p.lat_nominal?.toFixed(5)} N &nbsp; ${p.lon_nominal?.toFixed(5)} W
    </div>

    ${posActual}

    <div class="field-row">
      <div class="field"><div class="field-label">AIS TYPE</div><div class="field-value">${p.tipo_ais}</div></div>
      <div class="field"><div class="field-label">SWING RADIUS</div><div class="field-value">${p.radio_borneo_m || 0} m</div></div>
    </div>

    ${p.mmsi ? `<div class="field"><div class="field-label">MMSI</div><div class="field-value">${p.mmsi}</div></div>` : ''}

    ${lampBlock}

    <div class="divider"></div>

    <div class="field-row">
      <div class="field">
        <div class="field-label">PORT RESPONSIBLE</div>
        <div class="field-value">${p.puerto_responsable || '--'}</div>
      </div>
      <div class="field">
        <div class="field-label">COMPANY</div>
        <div class="field-value">${p.empresa_responsable || '--'}</div>
      </div>
    </div>

    ${p.observaciones ? `
    <div class="field">
      <div class="field-label">OBSERVATIONS</div>
      <div class="field-value" style="font-size:0.78rem;font-family:var(--sans)">${p.observaciones}</div>
    </div>` : ''}

    ${atonBlock}

    ${p.mmsi ? `
    <div class="divider"></div>
    <div class="batt-chart-hdr">
      <span class="field-label" style="margin:0">BATTERY HISTORY</span>
      <div class="batt-range-btns">
        <button class="batt-rb active" data-range="1">24 h</button>
        <button class="batt-rb" data-range="7">7 d</button>
        <button class="batt-rb" data-range="30">30 d</button>
      </div>
    </div>
    <div id="batt-chart-wrap">
      <div id="batt-chart-svg"></div>
      <div id="batt-chart-stats"></div>
    </div>` : ''}

    <button class="edit-btn" id="btn-edit-aid">EDIT AID DATA</button>
  `;

  document.getElementById('btn-edit-aid')?.addEventListener('click', () => {
    if (window.Modal) window.Modal.openEdit(p);
  });

  // Load battery chart if buoy has AIS
  if (p.mmsi) {
    const lamp  = (window._lampCache || []).find(l => l.id === p.lamp_id);
    const warnV = lamp?.warn_v  ?? null;
    const almV  = lamp?.alarm_v ?? null;
    _loadBatteryChart(p.mmsi, warnV, almV, 1,
                      p.lat_nominal, p.lon_nominal, p.caracteristica_luz);

    document.querySelectorAll('.batt-rb').forEach(btn => {
      btn.addEventListener('click', function() {
        document.querySelectorAll('.batt-rb').forEach(b => b.classList.remove('active'));
        this.classList.add('active');
        _loadBatteryChart(p.mmsi, warnV, almV, Number(this.dataset.range),
                          p.lat_nominal, p.lon_nominal, p.caracteristica_luz);
      });
    });
  }
}

// ── Sunrise/sunset (NOAA simplified, accurate ±2 min, no external dependency)
function _sunTimes(lat, lon, dateUTC) {
  var D   = Math.floor(dateUTC / 86400000);
  var JD  = D + 2440587.5;
  var n   = JD - 2451545.0;
  var L   = ((280.46 + 0.9856474 * n) % 360 + 360) % 360;
  var g   = ((357.528 + 0.9856003 * n) % 360 + 360) % 360;
  var lam = L + 1.915 * Math.sin(g * Math.PI/180) + 0.020 * Math.sin(2*g*Math.PI/180);
  var eps = 23.439 - 0.0000004 * n;
  var sinDec = Math.sin(eps * Math.PI/180) * Math.sin(lam * Math.PI/180);
  var dec    = Math.asin(sinDec);
  var latR   = lat * Math.PI / 180;
  var cosHA  = (Math.cos((-0.833)*Math.PI/180) - Math.sin(latR)*sinDec)
               / (Math.cos(latR) * Math.cos(dec));
  if (Math.abs(cosHA) > 1) return null;         // polar day/night
  var HA  = Math.acos(cosHA) * 180 / Math.PI;
  var B   = 360/365 * (n - 81) * Math.PI/180;
  var EoT = 9.87*Math.sin(2*B) - 7.53*Math.cos(B) - 1.5*Math.sin(B); // minutes
  var noon = 720 - 4*lon - EoT;                 // solar noon in minutes from midnight UTC
  var dayMs = D * 86400000;
  return {
    rise: new Date(dayMs + (noon - HA*4) * 60000),
    set:  new Date(dayMs + (noon + HA*4) * 60000),
  };
}

// Build day/night band list for a time range (one entry per solar transition)
function _solarBands(lat, lon, tMin, tMax) {
  var bands = [];
  var d = new Date(tMin); d.setUTCHours(0,0,0,0);
  while (d.getTime() <= tMax) {
    var st = _sunTimes(lat, lon, d.getTime());
    if (st) {
      bands.push({ t: Math.max(tMin, st.rise.getTime()), type: 'day'  });
      bands.push({ t: Math.max(tMin, st.set.getTime()),  type: 'night'});
    }
    d.setUTCDate(d.getUTCDate() + 1);
  }
  // Determine opening phase
  var firstSt = _sunTimes(lat, lon, tMin);
  var openNight = firstSt ? (tMin < firstSt.rise.getTime() || tMin > firstSt.set.getTime()) : true;
  bands.sort((a,b) => a.t - b.t);
  return { bands, openNight };
}

// ── Flash character parser → power consumption index (base = Fl 5s = 1.0)
function _parseFlashChar(char) {
  if (!char) return { label: '—', index: 1.0 };
  var c = char.trim().toUpperCase();
  if (/^F\b/.test(c) && !c.startsWith('FL'))
    return { label: 'Fixed (continuous, max consumption)', index: 12.0 };
  if (/^OC|^OCC/.test(c))  return { label: 'Occulting (~75% on)', index: 5.0 };
  if (/^ISO/.test(c))       return { label: 'Isophase (50% on)',   index: 3.0 };
  if (/^VQ/.test(c))        return { label: 'Very Quick ~120/min', index: 2.5 };
  if (/^IQ|^Q/.test(c))     return { label: 'Quick ~60/min',       index: 1.5 };
  // Fl(N) Xs
  var grp = c.match(/FL\((\d+)\)\s*(\d+(?:\.\d+)?)S/);
  if (grp) {
    var f = parseInt(grp[1]), p2 = parseFloat(grp[2]);
    var rate = f / p2 * 60;
    return { label: `Fl(${f}) ${p2}s — ${rate.toFixed(1)} fl/min`,
             index: Math.max(0.3, Math.min(4.0, rate / 12)) };
  }
  // Fl Xs
  var fl = c.match(/FL(?:\(1\))?\s*(\d+(?:\.\d+)?)S/);
  if (fl) {
    var per = parseFloat(fl[1]);
    var rate2 = 60 / per;
    return { label: `Fl ${per}s — ${rate2.toFixed(1)} fl/min`,
             index: Math.max(0.2, Math.min(2.0, rate2 / 12)) };
  }
  return { label: char, index: 1.0 };
}

// ── Battery history chart ─────────────────────────────────────────────────
async function _loadBatteryChart(mmsi, warnV, almV, days, lat, lon, flashChar) {
  var svgEl   = document.getElementById('batt-chart-svg');
  var statsEl = document.getElementById('batt-chart-stats');
  if (!svgEl) return;
  svgEl.innerHTML = '<div style="font-size:0.68rem;color:var(--text-muted);padding:8px">Loading…</div>';
  if (statsEl) statsEl.innerHTML = '';

  try {
    var from = new Date(Date.now() - days * 86400000).toISOString();
    var r    = await fetch(`${window.API}/tracks/atons/${mmsi}?from=${from}&limit=8000`,
      { headers: Auth.session ? { Authorization: 'Bearer ' + Auth.session.token } : {} });
    if (!r.ok) throw new Error('API error');
    var rows = await r.json();

    var pts = rows.filter(function(d){ return d.voltage_v != null; })
                  .map(function(d){ return { t: new Date(d.ts).getTime(), v: d.voltage_v }; });

    if (pts.length < 2) {
      svgEl.innerHTML = '<div style="font-size:0.68rem;color:var(--text-muted);padding:8px">Not enough data for this period.</div>';
      return;
    }

    // Solar bands for day/night shading
    var solar = (lat != null && lon != null)
      ? _solarBands(lat, lon, pts[0].t, pts[pts.length-1].t)
      : null;

    // Segment data into day/night for separate rate calculation
    var chargeRates = [], dischargeRates = [];
    if (solar) {
      var bands = solar.bands;
      var classify = function(ts) {
        var type = solar.openNight ? 'night' : 'day';
        for (var i = 0; i < bands.length; i++) {
          if (ts >= bands[i].t) type = bands[i].type;
          else break;
        }
        return type;
      };
      for (var i = 1; i < pts.length; i++) {
        var prev = pts[i-1], cur = pts[i];
        var midT = (prev.t + cur.t) / 2;
        var dV   = cur.v - prev.v;
        var dH   = (cur.t - prev.t) / 3600000;
        if (dH < 0.001) continue;
        var rate = dV / dH;
        if (classify(midT) === 'day') chargeRates.push(rate);
        else                          dischargeRates.push(rate);
      }
    }

    var avgRate = function(arr) {
      if (!arr.length) return null;
      return arr.reduce(function(s,v){return s+v;},0) / arr.length;
    };
    var chargeRate    = avgRate(chargeRates);
    var dischargeRate = avgRate(dischargeRates);

    svgEl.innerHTML = _battSvg(pts, warnV, almV, solar);

    // ── Stats panel ───────────────────────────────────────────────────────
    if (statsEl) {
      var latest  = pts[pts.length-1].v;
      var flash   = _parseFlashChar(flashChar);

      var fmtRate = function(v) {
        if (v === null) return '—';
        return (v >= 0 ? '+' : '') + v.toFixed(3) + ' V/h';
      };

      var etaHtml = '';
      if (almV != null && dischargeRate !== null && dischargeRate < 0) {
        var nightsLeft = (latest - almV) / Math.abs(dischargeRate) / 12; // approx 12h night
        if (nightsLeft > 0) {
          var daysLeft = Math.floor(nightsLeft);
          etaHtml = `<span class="batt-stat">Nights to alarm: <b style="color:var(--red)">${daysLeft}</b></span>`;
        }
      }

      var chargeHtml = chargeRate !== null
        ? `<span class="batt-stat">☀ Charge: <b style="color:#4caf50">${fmtRate(chargeRate)}</b></span>` : '';
      var dischHtml = dischargeRate !== null
        ? `<span class="batt-stat">☾ Discharge: <b style="color:var(--yellow)">${fmtRate(dischargeRate)}</b></span>` : '';

      var flashHtml = `<span class="batt-stat" title="${flash.label}">
        ⚡ Consumption index: <b style="color:${flash.index > 3 ? 'var(--red)' : flash.index > 1.5 ? 'var(--yellow)' : '#4caf50'}">${flash.index.toFixed(1)}×</b>
        <span style="color:var(--text-muted);font-size:0.62rem">${flash.label}</span>
      </span>`;

      statsEl.innerHTML = `
        <div class="batt-stats">
          <span class="batt-stat">Now: <b>${latest.toFixed(2)} V</b></span>
          ${chargeHtml}
          ${dischHtml}
          ${etaHtml}
          ${flashHtml}
        </div>`;
    }
  } catch(e) {
    if (svgEl) svgEl.innerHTML = '<div style="font-size:0.68rem;color:var(--red);padding:8px">Error loading data.</div>';
  }
}

function _battSvg(pts, warnV, almV, solar) {
  var W = 280, H = 100, PAD = { t: 6, r: 28, b: 18, l: 34 };
  var pw = W - PAD.l - PAD.r, ph = H - PAD.t - PAD.b;

  var tMin = pts[0].t, tMax = pts[pts.length-1].t;
  var vVals = pts.map(function(p){return p.v;});
  var vMin  = Math.min.apply(null, vVals.concat(almV != null ? [almV] : [])) * 0.985;
  var vMax  = Math.max.apply(null, vVals.concat(warnV != null ? [warnV] : [])) * 1.008;
  if (vMax <= vMin) vMax = vMin + 0.5;

  var tx = function(t) { return PAD.l + (t - tMin) / (tMax - tMin) * pw; };
  var ty = function(v) { return PAD.t + ph - (v - vMin) / (vMax - vMin) * ph; };

  // ── Day/night background bands ──────────────────────────────────────────
  var bands = '';
  if (solar) {
    var events = [{ t: tMin, type: solar.openNight ? 'night' : 'day' }]
                  .concat(solar.bands)
                  .concat([{ t: tMax, type: '_end' }]);
    for (var i = 0; i < events.length - 1; i++) {
      var ev = events[i], next = events[i+1];
      var x1 = tx(Math.max(tMin, ev.t)), x2 = tx(Math.min(tMax, next.t));
      if (x2 <= x1) continue;
      var fill = ev.type === 'day' ? 'rgba(255,220,80,0.06)' : 'rgba(10,20,60,0.45)';
      bands += `<rect x="${x1.toFixed(1)}" y="${PAD.t}" width="${(x2-x1).toFixed(1)}"
                      height="${ph}" fill="${fill}"/>`;
    }
  }

  // ── Threshold lines ─────────────────────────────────────────────────────
  var warnLine = '', almLine = '';
  if (warnV != null && warnV >= vMin && warnV <= vMax) {
    var wy = ty(warnV).toFixed(1);
    warnLine = `<line x1="${PAD.l}" y1="${wy}" x2="${W-PAD.r}" y2="${wy}"
                      stroke="#ffb300" stroke-width="0.8" stroke-dasharray="4,3"/>
                <text x="${W-PAD.r+2}" y="${(+wy+3).toFixed(1)}"
                      fill="#ffb300" font-size="7">${warnV.toFixed(2)}V</text>`;
  }
  if (almV != null && almV >= vMin && almV <= vMax) {
    var ay = ty(almV).toFixed(1);
    almLine = `<line x1="${PAD.l}" y1="${ay}" x2="${W-PAD.r}" y2="${ay}"
                     stroke="#f44336" stroke-width="0.8" stroke-dasharray="4,3"/>
               <text x="${W-PAD.r+2}" y="${(+ay+3).toFixed(1)}"
                     fill="#f44336" font-size="7">${almV.toFixed(2)}V</text>`;
  }

  // ── Data line ───────────────────────────────────────────────────────────
  var latest = pts[pts.length-1].v;
  var lineColor = almV != null && latest <= almV  ? '#f44336'
                : warnV != null && latest <= warnV ? '#ffb300'
                : '#00bfff';
  var line = pts.map(function(p){ return tx(p.t).toFixed(1)+','+ty(p.v).toFixed(1); }).join(' ');

  // Current value dot + label
  var curY = ty(latest).toFixed(1);
  var curDot = `<circle cx="${tx(tMax).toFixed(1)}" cy="${curY}" r="2.5" fill="${lineColor}"/>`;

  // ── Time axis labels ────────────────────────────────────────────────────
  var fmt = function(ts) {
    var d = new Date(ts);
    return (d.getDate()+'/'+(d.getMonth()+1)+' '
            +String(d.getUTCHours()).padStart(2,'0')+':'+String(d.getUTCMinutes()).padStart(2,'0'));
  };
  var timeAxis = `
    <text x="${PAD.l}" y="${H-2}" fill="#555" font-size="7">${fmt(tMin)}</text>
    <text x="${W-PAD.r}" y="${H-2}" text-anchor="end" fill="#555" font-size="7">${fmt(tMax)}</text>`;

  // ── Legend: ☀ / ☾ ───────────────────────────────────────────────────────
  var legend = solar ? `
    <text x="${PAD.l+2}" y="${PAD.t+8}" fill="rgba(255,220,80,0.6)" font-size="7">☀ day</text>
    <text x="${PAD.l+30}" y="${PAD.t+8}" fill="rgba(100,140,255,0.7)" font-size="7">☾ night</text>` : '';

  return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"
               style="width:100%;height:100px;display:block;background:#070b14;border-radius:4px">
    <defs>
      <clipPath id="batt-clip">
        <rect x="${PAD.l}" y="${PAD.t}" width="${pw}" height="${ph}"/>
      </clipPath>
    </defs>
    ${bands}
    <!-- axes -->
    <line x1="${PAD.l}" y1="${PAD.t}" x2="${PAD.l}" y2="${H-PAD.b}" stroke="#1e2535" stroke-width="0.8"/>
    <line x1="${PAD.l}" y1="${H-PAD.b}" x2="${W-PAD.r}" y2="${H-PAD.b}" stroke="#1e2535" stroke-width="0.8"/>
    ${warnLine}${almLine}
    <!-- data -->
    <g clip-path="url(#batt-clip)">
      <polyline points="${line}" fill="none" stroke="${lineColor}" stroke-width="1.5"
                stroke-linejoin="round" stroke-linecap="round"/>
    </g>
    ${curDot}
    ${legend}
    ${timeAxis}
  </svg>`;
}

window.toggleAtonField = function(field, show) {
  const el = document.getElementById('af-' + field);
  if (el) el.closest('label').style.display = show ? '' : 'none';
};

function shipTypeName(tipo) {
  if (tipo >= 60 && tipo < 70) return 'PASSENGER';
  if (tipo >= 70 && tipo < 80) return 'CARGO';
  if (tipo >= 80 && tipo < 90) return 'TANKER';
  if (tipo === 52 || tipo === 21) return 'TUG';
  if (tipo === 30) return 'FISHING';
  return `TYPE ${tipo}`;
}

function formatUTC(ts) {
  if (!ts) return '--';
  return new Date(ts).toUTCString().replace('GMT', 'UTC');
}

function _ageStr(ts) {
  if (!ts) return '';
  const sec = Math.round((Date.now() - new Date(ts).getTime()) / 1000);
  if (sec <  60)   return `${sec}s ago`;
  if (sec < 3600)  return `${Math.round(sec / 60)} min ago`;
  return `${Math.round(sec / 3600)} h ago`;
}

// ── Coordenadas en barra inferior ─────────────────────────────────────────
map.on('pointermove', (evt) => {
  const coord = ol.proj.toLonLat(evt.coordinate);
  const lat = coord[1].toFixed(4);
  const lon = coord[0].toFixed(4);
  document.getElementById('map-coords').textContent =
    `LAT ${lat > 0 ? '+' : ''}${lat}  LON ${lon > 0 ? '+' : ''}${lon}`;
});

// ── Áreas de operación ─────────────────────────────────────────────────────
const AREAS = {
  barranquilla: {
    label:  'Barranquilla / Bocas de Ceniza',
    lat:    10.9878, lon: -74.8040,
    zoom:   13,
    // Cuando haya cartas S-57: celdas CO4D0B00, CO4D0C00
    charts: ['CO4D0B00', 'CO4D0C00'],
  },
  cartagena: {
    label:  'Cartagena',
    lat:    10.4236, lon: -75.5478,
    zoom:   13,
    charts: ['CO4D0A00'],
  },
  santamarta: {
    label:  'Santa Marta',
    lat:    11.2408, lon: -74.2041,
    zoom:   13,
    charts: ['CO4D0D00'],
  },
  buenaventura: {
    label:  'Buenaventura',
    lat:    3.8800,  lon: -77.0300,
    zoom:   13,
    charts: ['CO4P0A00'],
  },
  colombia: {
    label:  'Colombia — Overview',
    lat:    7.5000,  lon: -76.0000,
    zoom:   6,
    charts: [],
  },
  miami: {
    label:  'Miami (Test)',
    lat:    25.7743, lon: -80.1937,
    zoom:   11,
    charts: ['US5FL12M'],   // celda NOAA ENC Miami
  },
};

function flyToArea(areaKey) {
  const area = AREAS[areaKey];
  if (!area) return;

  map.getView().animate({
    center:   ol.proj.fromLonLat([area.lon, area.lat]),
    zoom:     area.zoom,
    duration: 1200,
    easing:   ol.easing.easeOut,
  });

  // Mostrar en barra inferior qué área está activa
  document.getElementById('map-coords').textContent =
    `AREA: ${area.label.toUpperCase()}  |  LAT ${area.lat.toFixed(4)}  LON ${area.lon.toFixed(4)}`;

  // TODO: cuando haya cartas S-57, activar celdas: area.charts
  console.info('Chart cells for this area:', area.charts);
}

window.flyToCoords = function(lon, lat, zoom) {
  map.getView().animate({
    center:   ol.proj.fromLonLat([lon, lat]),
    zoom:     zoom,
    duration: 1200,
    easing:   ol.easing.easeOut,
  });
  document.getElementById('map-coords').textContent =
    `LAT ${lat.toFixed(4)}  LON ${lon.toFixed(4)}`;
};

// ── NOAA nearby charts panel ───────────────────────────────────────────────
let _cnpDismissed = false;
document.querySelector('.cnp-close')?.addEventListener('click', (e) => {
  e.stopPropagation();
  _cnpDismissed = true;
  document.getElementById('chart-nearby-panel').classList.add('hidden');
});
window._cnpClose = function() {
  _cnpDismissed = true;
  document.getElementById('chart-nearby-panel').classList.add('hidden');
};

let _chartQueryTimer = null;

map.getView().on('change', () => {
  clearTimeout(_chartQueryTimer);
  _chartQueryTimer = setTimeout(queryNearbyCharts, 1500);
});

async function queryNearbyCharts() {
  const ext  = ol.proj.transformExtent(map.getView().calculateExtent(), 'EPSG:3857', 'EPSG:4326');
  const [west, south, east, north] = ext;
  const panel = document.getElementById('chart-nearby-panel');
  const body  = document.getElementById('cnp-body');

  try {
    const r = await fetch(`/charts/noaa-nearby?west=${west}&south=${south}&east=${east}&north=${north}`);
    const cells = await r.json();

    if (!cells.length) {
      // Not in NOAA coverage (e.g. Colombia) — suggest UKHO/DIMAR
      body.innerHTML = `
        <div class="cnp-noaa-note">No free NOAA charts for this area.</div>
        <div class="cnp-ukho-link" onclick="window.open('https://www.admiralty.co.uk/digital-services/digital-charts/admiralty-vector-chart-service','_blank')">
          → Buy UKHO / AVCS charts for this region
        </div>
        <div class="cnp-ukho-link" onclick="window.open('https://www.dimar.mil.co','_blank')">
          → DIMAR — Colombia official charts
        </div>`;
      if (!_cnpDismissed) panel.classList.remove('hidden');
      return;
    }

    // Show up to 8 cells, prefer higher scale (more detail)
    const top = cells.slice(0, 8);
    body.innerHTML = top.map(c => `
      <div class="cnp-row">
        <span class="cnp-cell">${c.id}</span>
        <span class="cnp-name">${c.name || c.id}</span>
        <span class="cnp-scale">1:${(c.scale||0).toLocaleString()}</span>
        ${c.installed
          ? `<span class="cnp-btn installed">INSTALLED</span>`
          : `<button class="cnp-btn" onclick="downloadNOAA('${c.id}')">DOWNLOAD</button>`}
      </div>`).join('') +
      `<div class="cnp-noaa-note">NOAA free ENCs · ${cells.length} cell(s) in view</div>`;
    if (!_cnpDismissed) panel.classList.remove('hidden');
  } catch(e) {
    panel.classList.add('hidden');
  }
}

// ── Language toggle ────────────────────────────────────────────────────────
const LANG = {
  en: {
    'nav.settings':'SETTINGS','nav.charts':'CHARTS','nav.reports':'REPORTS','nav.users':'USERS',
    'dd.datasource':'Data Source / AIS','dd.station':'Station & Antenna','dd.alerts':'Alert Parameters',
    'dd.installed':'Installed Charts','dd.install':'Install Chart Cell (.000)','dd.noaa':'Download NOAA Free Charts',
    'dd.recordings':'Vessel Recordings (VDR)','dd.history':'AIS Track History','dd.export':'Export Data (CSV)',
    'dd.manage':'Manage Users','dd.create':'Create User',
    'tb.port':'PORT','tb.port.ph':'Search worldwide port...',
    'tb.display':'DISPLAY',
    'tb.all':'ALL','tb.vessels':'VESSELS','tb.aids':'AIDS','tb.alerts':'ALERTS',
    'tb.layer':'LAYER','tb.world':'WORLD','tb.seamark':'SEAMARK',
    'tb.land':'LAND','tb.bathy':'BATHY','tb.hazards':'HAZARDS','tb.zones':'ZONES',
    'tb.night':'NIGHT','tb.day':'DAY',
    'stat.vessels':'VESSELS','stat.aids':'AIDS','stat.link':'LINK',
    'sector.info':'OBJECT INFO','sector.events':'EVENTS & ALERTS',
    'no.sel':'SELECT OBJECT ON CHART',
    'btn.edit':'EDIT AID DATA','btn.track':'TRACK & PROJECT HEADING',
  },
  es: {
    'nav.settings':'AJUSTES','nav.charts':'CARTAS','nav.reports':'REPORTES','nav.users':'USUARIOS',
    'dd.datasource':'Fuente de datos / AIS','dd.station':'Estación y Antena','dd.alerts':'Parámetros de Alerta',
    'dd.installed':'Cartas instaladas','dd.install':'Instalar celda (.000)','dd.noaa':'Descargar cartas NOAA',
    'dd.recordings':'Registros de buques (VDR)','dd.history':'Historial AIS','dd.export':'Exportar datos (CSV)',
    'dd.manage':'Gestionar usuarios','dd.create':'Crear usuario',
    'tb.port':'PUERTO','tb.port.ph':'Buscar puerto mundial...',
    'tb.display':'MOSTRAR',
    'tb.all':'TODO','tb.vessels':'BUQUES','tb.aids':'AYUDAS','tb.alerts':'ALERTAS',
    'tb.layer':'CAPA','tb.world':'MUNDO','tb.seamark':'BOYAS',
    'tb.land':'TIERRA','tb.bathy':'BATI','tb.hazards':'PELIGROS','tb.zones':'ZONAS',
    'tb.night':'NOCHE','tb.day':'DÍA',
    'stat.vessels':'BUQUES','stat.aids':'AYUDAS','stat.link':'ENLACE',
    'sector.info':'INFO OBJETO','sector.events':'EVENTOS Y ALARMAS',
    'no.sel':'SELECCIONE UN OBJETO EN LA CARTA',
    'btn.edit':'EDITAR AYUDA','btn.track':'RASTREAR Y PROYECTAR RUMBO',
  },
};

let currentLang = localStorage.getItem('ams_lang') || 'en';

function applyLang(lang) {
  currentLang = lang;
  localStorage.setItem('ams_lang', lang);
  const t = LANG[lang];
  document.querySelectorAll('[data-i18n]').forEach(el => {
    const key = el.dataset.i18n;
    if (t[key]) el.textContent = t[key];
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
    const key = el.dataset.i18nPlaceholder;
    if (t[key]) el.placeholder = t[key];
  });
  const btn = document.getElementById('toggle-lang');
  if (btn) { btn.textContent = lang === 'en' ? 'ES' : 'EN'; }
  window._currentLang = lang;
}

document.getElementById('toggle-lang')?.addEventListener('click', () => {
  applyLang(currentLang === 'en' ? 'es' : 'en');
});

// Apply saved lang on load
applyLang(currentLang);

// ── Toggle SeaMap ──────────────────────────────────────────────────────────
document.getElementById('toggle-seamap').addEventListener('click', (e) => {
  const visible = seaMapLayer.getVisible();
  seaMapLayer.setVisible(!visible);
  e.target.classList.toggle('active', !visible);
  // After OL processes the visibility change, recompute ENC cell hiding so
  // the toggle can't leave ENC sources in a stale state (Miami disappear bug).
  requestAnimationFrame(() => {
    _recomputeHiddenCells();
    [encSource, depthSource, landSource, zoneSource, hazardSource, soundSource]
      .forEach(src => src.changed());
  });
});

// ── Toggle ENC chart aids ───────────────────────────────────────────────────
document.getElementById('toggle-enc').addEventListener('click', (e) => {
  const visible = encLayer.getVisible();
  encLayer.setVisible(!visible);
  e.target.classList.toggle('active', !visible);
});

// ── Toggle depths (DEPARE + DEPCNT + SOUNDG + LANDMASK) ─────────────────────
document.getElementById('toggle-depths')?.addEventListener('click', (e) => {
  const willShow = !depthLayer.getVisible();
  depthLayer.setVisible(willShow);
  soundLayer.setVisible(willShow);   // sondas viajan junto a la batimetría
  e.target.classList.toggle('active', willShow);
  if (willShow) {
    _lastDepthBbox = null;
    window.loadChartDepths();
  }
});

// ── Toggle land / terrain (LNDARE, BUAARE, COALNE, structures) ───────────────
document.getElementById('toggle-land')?.addEventListener('click', (e) => {
  const willShow = !landLayer.getVisible();
  landLayer.setVisible(willShow);
  e.target.classList.toggle('active', willShow);
});

// ── Toggle hazards (WRECKS, OBSTRN, UWTROC) ──────────────────────────────────
document.getElementById('toggle-hazards')?.addEventListener('click', (e) => {
  const willShow = !hazardLayer.getVisible();
  hazardLayer.setVisible(willShow);
  e.target.classList.toggle('active', willShow);
});

// ── Toggle zones (RESARE, CTNARE, ACHARE, TSSLPT, FAIRWY…) ──────────────────
document.getElementById('toggle-zones')?.addEventListener('click', (e) => {
  const willShow = !zoneLayer.getVisible();
  zoneLayer.setVisible(willShow);
  e.target.classList.toggle('active', willShow);
});

// Reload depths whenever the user pans/zooms. The check inside loadChartDepths
// short-circuits if the layer is hidden or the new viewport is already cached.
map.on('moveend', _scheduleDepthReload);
// Recompute which cells are dominated by a higher-band cell on every move.
map.on('moveend', () => {
  _recomputeHiddenCells();
  [encSource, depthSource, landSource, zoneSource, hazardSource]
    .forEach(src => src.changed());
});
// First load: fire once the map has rendered its initial frame (viewport known)
map.once('rendercomplete', () => { _lastDepthBbox = null; window.loadChartDepths(); });
_loadChartCells();

// ── Toggle CHART mode — hides OSM/ocean tiles and renders the LANDMASK
//    polygon from the S-57 cells in paper-buff so the chart becomes the
//    sole basemap. Auto-enables DEPTHS because CHART mode without depth
//    fills shows nothing in the water. ──
document.getElementById('toggle-chart')?.addEventListener('click', (e) => {
  _chartModeActive = !_chartModeActive;
  e.target.classList.toggle('active', _chartModeActive);
  osmLayer.setVisible(!_chartModeActive);
  oceanRefLayer.setVisible(!_chartModeActive);
  if (_chartModeActive && !depthLayer.getVisible()) {
    document.getElementById('toggle-depths').click();
  }
  depthLayer.changed();   // force restyle so LANDMASK appears/disappears
});

// ── Toggle WORLD raster — hide/show OSM basemap independently of CHART mode.
document.getElementById('toggle-world')?.addEventListener('click', (e) => {
  const visible = !osmLayer.getVisible();
  osmLayer.setVisible(visible);
  e.target.classList.toggle('active', visible);
});

// ── Toggle Night Mode (dim tiles, keep aid/vessel layers at full brightness) ─
function applyNightMode(night) {
  osmLayer.setOpacity(night ? 0.45 : 1.0);
  oceanRefLayer.setOpacity(night ? 0.35 : 0.9);
  seaMapLayer.setOpacity(night ? 0.55 : 1.0);
}

document.getElementById('toggle-night').addEventListener('click', (e) => {
  const night = document.documentElement.classList.toggle('night');
  e.target.classList.toggle('active', night);
  const t = LANG[currentLang] || LANG.en;
  e.target.textContent = night ? (t['tb.day'] || 'DAY') : (t['tb.night'] || 'NIGHT');
  e.target.dataset.i18n = night ? 'tb.day' : 'tb.night';
  applyNightMode(night);
  localStorage.setItem('ams_night', night ? '1' : '');
});
if (localStorage.getItem('ams_night')) {
  document.documentElement.classList.add('night');
  document.getElementById('toggle-night').classList.add('active');
  document.getElementById('toggle-night').textContent = 'DAY';
  applyNightMode(true);
}

// ── SDR / AIS-catcher control ─────────────────────────────────────────────
const _sdrBtn = document.getElementById('btn-sdr');
let _sdrRunning = false;

async function _sdrRefreshStatus() {
  try {
    const r = await fetch('/ais/status');
    const s = await r.json();
    _sdrRunning = s.running;
    if (_sdrBtn) {
      _sdrBtn.classList.toggle('active', _sdrRunning);
      _sdrBtn.title = _sdrRunning
        ? `AIS-catcher running (PID ${s.pid ?? '?'}) — ${s.sdr_devices?.length ?? 0} SDR device(s)`
        : `Launch AIS-catcher — ${s.sdr_devices?.length ?? 0} RTL-SDR device(s) found`;
      if (!s.exe_found) _sdrBtn.title = 'AIS-catcher.exe not found';
    }
  } catch (e) { /* backend not ready */ }
}
_sdrRefreshStatus();

_sdrBtn?.addEventListener('click', async () => {
  // Always POST /ais/launch — the backend handles "already running" by syncing
  // the source to SDR + restarting the UDP listener, which is exactly the
  // recovery action a user wants when the button "looks active but no data".
  _sdrBtn.disabled = true;
  _sdrBtn.textContent = '...';
  try {
    const r = await fetch('/ais/launch', { method: 'POST' });
    const d = await r.json();
    if (d.ok) {
      _sdrRunning = true;
      _sdrBtn.classList.add('active');
      _sdrBtn.title = `AIS-catcher running (PID ${d.pid ?? '?'})`;
    } else {
      alert(`AIS-catcher launch failed:\n${d.error || JSON.stringify(d)}`);
    }
  } catch (e) {
    alert(`SDR launch error: ${e}`);
  } finally {
    _sdrBtn.disabled = false;
    _sdrBtn.textContent = 'SDR';
  }
});

// ── Historial y proyección de barcos ──────────────────────────────────────
const vesselHistory = new Map();   // mmsi → [{lat,lon,ts}]
const trackSource   = new ol.source.Vector();
const trackLayer    = new ol.layer.Vector({ source: trackSource, zIndex: 5 });
map.addLayer(trackLayer);

// ── Auto-trail — estela en tiempo real para todos los targets AIS ──────────
// Dibuja automáticamente el rastro de cada barco conforme llegan posiciones.
const trailSource = new ol.source.Vector();
const trailLayer  = new ol.layer.Vector({
  source: trailSource,
  zIndex: 6,
  style: function(feature) {
    var color = feature.get('color') || '#00bfff';
    return new ol.style.Style({
      stroke: new ol.style.Stroke({ color: color, width: 1.5, lineDash: [4, 4] }),
    });
  },
});
map.addLayer(trailLayer);

var _trailsVisible = true;
var _trailWindowMs = 6 * 60 * 1000;   // default: last 6 minutes

function _updateTrail(mmsi) {
  var hist = vesselHistory.get(mmsi);
  if (!hist) return;
  var cutoff = (_trailWindowMs > 0) ? (Date.now() - _trailWindowMs) : 0;
  var pts    = hist.filter(function(p) { return p.ts >= cutoff; });
  var feat   = trailSource.getFeatureById('trail_' + mmsi);
  if (pts.length < 2) {
    if (feat) trailSource.removeFeature(feat);
    return;
  }
  var coords = pts.map(function(p) { return ol.proj.fromLonLat([p.lon, p.lat]); });
  if (!feat) {
    var f = new ol.Feature({ geometry: new ol.geom.LineString(coords) });
    f.setId('trail_' + mmsi);
    f.set('color', '#00bfff');
    trailSource.addFeature(f);
  } else {
    feat.getGeometry().setCoordinates(coords);
  }
}

window.setTrailsVisible = function(v) { _trailsVisible = v; trailLayer.setVisible(v); };
window.setTrailWindow   = function(ms) {
  _trailWindowMs = ms;
  // Redraw all trails under the new window
  vesselHistory.forEach(function(hist, mmsi) { _updateTrail(mmsi); });
};

// ── AIS Vectors (True / Relative) ─────────────────────────────────────────
const vectorSource = new ol.source.Vector();
const vectorLayer  = new ol.layer.Vector({
  source: vectorSource,
  zIndex: 7,
  visible: false,
  style: new ol.style.Style({
    stroke: new ol.style.Stroke({ color: '#ffff00', width: 1.5 }),
  }),
});
map.addLayer(vectorLayer);

var _vectorsVisible = false;
var _vectorMode     = 'true';   // 'true' | 'relative'
var _vectorTimeMin  = 6;        // minutes ahead
var _ownShipCog     = 0;
var _ownShipSog     = 0;

function _updateVector(mmsi, lat, lon, sog, cog) {
  var feat = vectorSource.getFeatureById('vec_' + mmsi);
  if (!_vectorsVisible || !(sog > 0.1)) {
    if (feat) vectorSource.removeFeature(feat);
    return;
  }
  var effSog = sog, effCog = cog;
  if (_vectorMode === 'relative') {
    var ownVx  = _ownShipSog * Math.sin(_ownShipCog * Math.PI / 180);
    var ownVy  = _ownShipSog * Math.cos(_ownShipCog * Math.PI / 180);
    var tgtVx  = sog * Math.sin(cog * Math.PI / 180);
    var tgtVy  = sog * Math.cos(cog * Math.PI / 180);
    var relVx  = tgtVx - ownVx;
    var relVy  = tgtVy - ownVy;
    effSog     = Math.sqrt(relVx * relVx + relVy * relVy);
    effCog     = (Math.atan2(relVx, relVy) * 180 / Math.PI + 360) % 360;
  }
  var pts    = projectPath(lat, lon, effSog, effCog, _vectorTimeMin);
  var end    = pts[pts.length - 1];
  var coords = [ol.proj.fromLonLat([lon, lat]), ol.proj.fromLonLat(end)];
  if (!feat) {
    var f = new ol.Feature({ geometry: new ol.geom.LineString(coords) });
    f.setId('vec_' + mmsi);
    vectorSource.addFeature(f);
  } else {
    feat.getGeometry().setCoordinates(coords);
  }
}

function _refreshAllVectors() {
  vesselsSource.getFeatures().forEach(function(f) {
    var d = f.getProperties();
    if (d.mmsi != null) _updateVector(d.mmsi, d.lat, d.lon, d.sog || 0, d.cog || 0);
  });
}

window.setVectorsVisible = function(v) {
  _vectorsVisible = v;
  vectorLayer.setVisible(v);
  if (!v) vectorSource.clear();
  else _refreshAllVectors();
};
window.setVectorMode = function(m) { _vectorMode = m; _refreshAllVectors(); };
window.setVectorTime = function(t) { _vectorTimeMin = t; _refreshAllVectors(); };

// ── Indicadores de warning/alarma sobre ayudas ────────────────────────────
const warnSource = new ol.source.Vector();
const warnLayer  = new ol.layer.Vector({ source: warnSource, zIndex: 25 });
map.addLayer(warnLayer);

function warnStyle(level, visible) {
  if (!visible) return null;
  const isAlarm = level === 'alarm';
  const color   = isAlarm ? '#e53935' : '#f59e0b';
  const stroke  = isAlarm ? '#ff6b6b' : '#fcd34d';
  // Two styles: triangle offset + "!" text at same offset
  return [
    new ol.style.Style({
      image: new ol.style.RegularShape({
        points: 3, radius: 14,
        displacement: [22, 22],
        fill:   new ol.style.Fill({ color }),
        stroke: new ol.style.Stroke({ color: stroke, width: 1.5 }),
      }),
    }),
    new ol.style.Style({
      text: new ol.style.Text({
        text: '!',
        font: 'bold 11px Inter,sans-serif',
        fill:   new ol.style.Fill({ color: '#fff' }),
        offsetX: 22, offsetY: -22,
      }),
    }),
  ];
}

// Blink loop — alterna entre estilo visible y estilo vacío (invisible)
const STYLE_INVISIBLE = new ol.style.Style({});
let blinkOn = true;
setInterval(() => {
  blinkOn = !blinkOn;
  warnSource.getFeatures().forEach(f =>
    f.setStyle(blinkOn ? warnStyle(f.get('level'), true) : STYLE_INVISIBLE)
  );
}, 600);

function setAidWarning(aidFeature, level) {
  const aidId = aidFeature.getId();
  // Quitar indicador previo de esta ayuda si existe
  const existing = warnSource.getFeatureById('w_' + aidId);
  if (existing) warnSource.removeFeature(existing);
  if (!level) return;

  const geom = aidFeature.getGeometry().clone();
  // Desplazar el triángulo 18px arriba (en coordenadas mapa no es trivial, se superpone)
  const f = new ol.Feature({ geometry: geom, level });
  f.setId('w_' + aidId);
  f.setStyle(warnStyle(level, true));
  warnSource.addFeature(f);
}

function clearAllWarnings() { warnSource.clear(); }

window.setAidWarning    = setAidWarning;
window.clearAllWarnings = clearAllWarnings;

// ── Posición propia (GPS) ──────────────────────────────────────────────────
const ownShipSource = new ol.source.Vector();
map.addLayer(new ol.layer.Vector({
  source: ownShipSource,
  zIndex: 30,
  style: new ol.style.Style({
    image: new ol.style.RegularShape({
      points: 4, radius: 10, radius2: 0, angle: Math.PI / 4,
      fill:   new ol.style.Fill({ color: 'rgba(30,136,229,0.9)' }),
      stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }),
    }),
    text: new ol.style.Text({
      text: 'MY POSITION',
      offsetY: -18,
      font: '500 9px Inter,sans-serif',
      fill:   new ol.style.Fill({ color: '#90caf9' }),
      stroke: new ol.style.Stroke({ color: '#030810', width: 3 }),
    }),
  }),
}));

window.updateOwnShip = function(fix) {
  ownShipSource.clear();
  // Capture COG/SOG for relative vector mode
  _ownShipCog = fix.cog || 0;
  _ownShipSog = fix.sog || 0;
  const f = new ol.Feature({
    geometry: new ol.geom.Point(ol.proj.fromLonLat([fix.lon, fix.lat])),
  });
  ownShipSource.addFeature(f);
};

function recordPosition(mmsi, lat, lon) {
  if (!vesselHistory.has(mmsi)) vesselHistory.set(mmsi, []);
  const hist = vesselHistory.get(mmsi);
  const now  = Date.now();
  // Guardar solo si pasaron ≥30 s desde la última
  if (!hist.length || now - hist[hist.length - 1].ts >= 30000)
    hist.push({ lat, lon, ts: now });
  if (hist.length > 40) hist.shift();
  _updateTrail(mmsi);
}

function projectPath(lat, lon, sogKn, cogDeg, minutes = 20) {
  const sogMs  = sogKn * 0.5144;
  const cogRad = (cogDeg * Math.PI) / 180;
  const pts    = [[lon, lat]];
  for (let t = 1; t <= minutes; t++) {
    const d    = sogMs * t * 60;
    const dLat = (d * Math.cos(cogRad)) / 111320;
    const dLon = (d * Math.sin(cogRad)) / (111320 * Math.cos(lat * Math.PI / 180));
    pts.push([lon + dLon, lat + dLat]);
  }
  return pts;
}

function haversineM(lat1, lon1, lat2, lon2) {
  const R = 6371000, r = Math.PI / 180;
  const dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
  const a = Math.sin(dLat/2)**2 + Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLon/2)**2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}

let activeTrackMmsi = null;

window.toggleTrack = function(mmsi, lat, lon, sog, cog) {
  // Limpiar track anterior
  trackSource.clear();
  if (activeTrackMmsi === mmsi) { activeTrackMmsi = null; clearAllWarnings(); return; }
  activeTrackMmsi = mmsi;

  const hist = vesselHistory.get(mmsi) || [];

  // Track histórico
  if (hist.length >= 2) {
    const histCoords = hist.map(p => ol.proj.fromLonLat([p.lon, p.lat]));
    trackSource.addFeature(new ol.Feature({
      geometry: new ol.geom.LineString(histCoords),
      kind: 'history',
    }));
  }

  // Proyección de rumbo (20 min)
  if (sog > 0.3) {
    const proj = projectPath(lat, lon, sog, cog, 20);
    const projCoords = proj.map(p => ol.proj.fromLonLat(p));
    trackSource.addFeature(new ol.Feature({
      geometry: new ol.geom.LineString(projCoords),
      kind: 'projection',
    }));

    // Detectar colisiones con ayudas y marcarlas en el mapa
    clearAllWarnings();
    const warnings = [];
    aidsSource.getFeatures().forEach(aid => {
      const ac      = ol.proj.toLonLat(aid.getGeometry().getCoordinates());
      const borneo  = aid.get('radio_borneo_m') || 30;
      let closest   = Infinity, closestEta = 0;
      proj.forEach((pt, i) => {
        const d = haversineM(pt[1], pt[0], ac[1], ac[0]);
        if (d < closest) { closest = d; closestEta = i; }
      });
      const level = closest < borneo + 50  ? 'alarm'
                  : closest < borneo + 200 ? 'warning'
                  : null;
      if (level) {
        setAidWarning(aid, level);
        warnings.push({ nombre: aid.get('nombre'), eta: closestEta, dist: Math.round(closest), level });
      }
    });

    // Actualizar panel con warnings
    const warnEl = document.getElementById('track-warnings');
    if (warnEl) {
      if (warnings.length) {
        warnEl.innerHTML = warnings.map(w =>
          `<div class="track-warn ${w.level === 'alarm' ? 'track-alarm' : ''}">${w.level === 'alarm' ? '🔴' : '🟡'} ${w.nombre} — ETA ~${w.eta} min  (${w.dist} m)</div>`
        ).join('');
        warnEl.style.display = 'block';
      } else {
        warnEl.innerHTML = '<div style="color:var(--green);font-size:0.72rem">No aids in projected path.</div>';
        warnEl.style.display = 'block';
      }
    }
  }
};

trackLayer.setStyle((feature) => {
  const kind = feature.get('kind');
  if (kind === 'history') return new ol.style.Style({
    stroke: new ol.style.Stroke({ color: '#06b6d4', width: 1.5, lineDash: [5, 4] }),
  });
  if (kind === 'projection') return new ol.style.Style({
    stroke: new ol.style.Stroke({ color: '#f59e0b', width: 1.5, lineDash: [8, 5] }),
  });
});

// ── Filtros ────────────────────────────────────────────────────────────────
document.querySelectorAll('.tb-btn[data-filter]').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.tb-btn[data-filter]').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    const f = btn.dataset.filter;
    vesselsLayer.setVisible(f === 'all' || f === 'vessels');
    aidsLayer.setVisible(f === 'all' || f === 'aids');
  });
});

// ── Reloj UTC ──────────────────────────────────────────────────────────────
setInterval(() => {
  document.getElementById('clock').textContent =
    new Date().toUTCString().slice(17, 25);
}, 1000);

// ── Divisor arrastrable ────────────────────────────────────────────────────
(function() {
  const divider    = document.getElementById('panel-divider');
  const infoSector = document.getElementById('info-sector');
  const evSector   = document.getElementById('events-sector');
  let dragging = false, startY = 0, startInfo = 0, startEv = 0;

  divider.addEventListener('mousedown', (e) => {
    dragging  = true;
    startY    = e.clientY;
    startInfo = infoSector.getBoundingClientRect().height;
    startEv   = evSector.getBoundingClientRect().height;
    divider.classList.add('dragging');
    document.body.style.cursor = 'ns-resize';
    document.body.style.userSelect = 'none';
  });

  document.addEventListener('mousemove', (e) => {
    if (!dragging) return;
    const dy      = e.clientY - startY;
    const newInfo = Math.max(80, startInfo + dy);
    const newEv   = Math.max(80, startEv - dy);
    infoSector.style.flex = `0 0 ${newInfo}px`;
    evSector.style.flex   = `0 0 ${newEv}px`;
  });

  document.addEventListener('mouseup', () => {
    if (!dragging) return;
    dragging = false;
    divider.classList.remove('dragging');
    document.body.style.cursor = '';
    document.body.style.userSelect = '';
  });
})();

// Exponer aidsSource globalmente para el formulario
window.aidsSource = aidsSource;

// ── Port constraints for USER role ────────────────────────────────────────
// Called after login when the user has a company → port.
// ── Port constraint state ─────────────────────────────────────────────────────
let _portMinZoom    = null;   // minimum zoom for client users
let _portExtent     = null;   // [minX,minY,maxX,maxY] in EPSG:3857
let _constraintLock = false;  // prevents re-entrant moveend feedback loop

// Single moveend listener — hard backstop for zoom + pan constraints.
// Lock prevents the corrective setCenter/setZoom from re-triggering the
// listener in a loop that would cause sluggishness.
map.on('moveend', function() {
  if (_constraintLock) return;
  if (_portMinZoom === null && !_portExtent) return;

  const view    = map.getView();
  let newZoom   = view.getZoom();
  let newCenter = view.getCenter().slice();
  let needsFix  = false;

  // Zoom floor
  if (_portMinZoom !== null && newZoom < _portMinZoom) {
    newZoom  = _portMinZoom;
    needsFix = true;
  }

  // Pan bounds — clamp center to chart extent
  if (_portExtent) {
    const cx = Math.max(_portExtent[0], Math.min(_portExtent[2], newCenter[0]));
    const cy = Math.max(_portExtent[1], Math.min(_portExtent[3], newCenter[1]));
    if (cx !== newCenter[0] || cy !== newCenter[1]) {
      newCenter = [cx, cy];
      needsFix  = true;
    }
  }

  if (needsFix) {
    _constraintLock = true;
    view.setCenter(newCenter);
    view.setZoom(newZoom);
    setTimeout(function() { _constraintLock = false; }, 150);
  }
});

window._setPortConstraints = function(port) {
  if (!port || port.center_lat == null) return;

  _portMinZoom = port.default_zoom || 12;

  // Extent: real ENC chart bbox preferred, fallback ±0.25° pad.
  // chart_bbox = [west, south, east, north] WGS-84.
  if (port.chart_bbox && port.chart_bbox.length === 4) {
    const [west, south, east, north] = port.chart_bbox;
    const sw = ol.proj.fromLonLat([west,  south]);
    const ne = ol.proj.fromLonLat([east,  north]);
    _portExtent = [sw[0], sw[1], ne[0], ne[1]];
  } else {
    const pad = 0.25;
    const sw  = ol.proj.fromLonLat([port.center_lon - pad, port.center_lat - pad]);
    const ne  = ol.proj.fromLonLat([port.center_lon + pad, port.center_lat + pad]);
    _portExtent = [sw[0], sw[1], ne[0], ne[1]];
  }

  map.getView().setMinZoom(_portMinZoom);
};

window._clearPortConstraints = function() {
  _portMinZoom = null;
  _portExtent  = null;
  map.getView().setMinZoom(0);
};

// Returns true once the map view is usable.
window._mapReady = function() {
  try { return !!map.getView(); } catch (_) { return false; }
};

// Call after the map container becomes visible (app shown after login).
// OL initializes with display:none → size=0×0. updateSize() fixes both
// the rendering size and re-anchors the center to the correct position.
window._mapUpdateSize = function() {
  map.updateSize();
};

// Jump to position immediately — no animation (used for client role login).
window._mapJump = function(lon, lat, zoom) {
  const view = map.getView();
  view.setCenter(ol.proj.fromLonLat([lon, lat]));
  view.setZoom(zoom);
};

// ── DVR Playback hooks ─────────────────────────────────────────────────────
// dvr.js calls these to move a marker and draw the track on the map.
(function() {
  const dvrSource = new ol.source.Vector();
  const dvrLayer  = new ol.layer.Vector({
    source:  dvrSource,
    zIndex:  50,   // above everything
    visible: false,
  });
  map.addLayer(dvrLayer);

  // Feature IDs
  const MARKER_ID = '__dvr_marker__';
  const TRACK_ID  = '__dvr_track__';

  // ── Move playback marker ─────────────────────────────────────────────────
  window._dvrMapMove = function(lon, lat, cog) {
    dvrLayer.setVisible(true);
    let feat = dvrSource.getFeatureById(MARKER_ID);
    const coord = ol.proj.fromLonLat([lon, lat]);
    if (!feat) {
      feat = new ol.Feature({ geometry: new ol.geom.Point(coord) });
      feat.setId(MARKER_ID);
      feat.setStyle(new ol.style.Style({
        image: new ol.style.RegularShape({
          fill:   new ol.style.Fill({ color: '#00e5ff' }),
          stroke: new ol.style.Stroke({ color: '#003344', width: 1.5 }),
          points: 3,
          radius: 10,
          rotation: ((cog || 0) * Math.PI) / 180,
          rotateWithView: true,
        }),
        zIndex: 100,
      }));
      dvrSource.addFeature(feat);
    } else {
      feat.getGeometry().setCoordinates(coord);
      // Update triangle rotation
      const s = feat.getStyle();
      if (s && s.getImage()) {
        s.getImage().setRotation(((cog || 0) * Math.PI) / 180);
      }
    }
    // Pan map to keep marker in view if it's near the edge
    const view = map.getView();
    const extent = view.calculateExtent(map.getSize());
    if (!ol.extent.containsCoordinate(extent, coord)) {
      view.animate({ center: coord, duration: 400 });
    }
  };

  // ── Draw full track polyline ─────────────────────────────────────────────
  window._dvrMapShowTrack = function(track) {
    dvrLayer.setVisible(true);

    // Remove existing track line
    const existing = dvrSource.getFeatureById(TRACK_ID);
    if (existing) dvrSource.removeFeature(existing);

    if (!track || !track.length) return;

    const coords = track
      .filter(p => p.lat != null && p.lon != null)
      .map(p => ol.proj.fromLonLat([p.lon, p.lat]));

    if (coords.length < 2) return;

    const line = new ol.Feature({
      geometry: new ol.geom.LineString(coords),
    });
    line.setId(TRACK_ID);
    line.setStyle(new ol.style.Style({
      stroke: new ol.style.Stroke({
        color: '#00e5ff',
        width: 2,
        lineDash: [6, 4],
      }),
    }));
    dvrSource.addFeature(line);

    // Fit view to track extent with padding
    const ext = dvrSource.getExtent();
    map.getView().fit(ext, { padding: [60, 60, 60, 60], duration: 800 });
  };

  // ── Clear DVR layer (called on stop) ────────────────────────────────────
  window._dvrMapClear = function() {
    dvrSource.clear();
    dvrLayer.setVisible(false);
  };
})();

// ── AIS toolbar controls ───────────────────────────────────────────────────
document.getElementById('btn-trails').addEventListener('click', function(e) {
  const on = e.target.classList.toggle('active');
  window.setTrailsVisible(on);
});
document.getElementById('trail-window').addEventListener('change', function(e) {
  window.setTrailWindow(Number(e.target.value));
});
document.getElementById('btn-vectors').addEventListener('click', function(e) {
  const on = e.target.classList.toggle('active');
  window.setVectorsVisible(on);
});
document.getElementById('vector-mode').addEventListener('change', function(e) {
  window.setVectorMode(e.target.value);
});
document.getElementById('vector-time').addEventListener('change', function(e) {
  window.setVectorTime(Number(e.target.value));
});
