'use strict';
/* S-57 ENC chart layer for GPS Navigator.
   Attaches to GPSMap.getOLMap() and exposes window.ChartLayer. */

const ChartLayer = (function () {

  // ── Canvas symbol cache ──────────────────────────────────────────────────
  const _cache = {};
  function _ci(key, fn) { if (!_cache[key]) _cache[key] = fn(); return _cache[key]; }

  // ── S-57 colour code → CSS colour ────────────────────────────────────────
  const S57_CSS = {
    1:'#ffffff', 2:'#111111', 3:'#e53935', 4:'#2e7d32',
    5:'#1565c0', 6:'#f9a825', 7:'#78909c', 8:'#6d4c41',
    9:'#ff8f00', 10:'#7b1fa2', 11:'#ef6c00', 12:'#e91e63',
  };
  function _s57css(c) { return S57_CSS[c] || '#78909c'; }

  // ── Canvas helpers ───────────────────────────────────────────────────────
  function _mkC(sz) {
    const c = document.createElement('canvas'); c.width = sz; c.height = sz; return c;
  }
  function _wl(ctx, cx, y, hw) {
    ctx.strokeStyle = '#1a6bb5'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(cx - hw, y); ctx.lineTo(cx + hw, y); ctx.stroke();
  }
  function _st(ctx, cx, y1, y2) {
    ctx.strokeStyle = '#222'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(cx, y1); ctx.lineTo(cx, y2); ctx.stroke();
  }
  function _tmConeUp(ctx, cx, y0, w, h, fill) {
    ctx.beginPath(); ctx.moveTo(cx, y0); ctx.lineTo(cx+w/2, y0+h); ctx.lineTo(cx-w/2, y0+h); ctx.closePath();
    ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke();
  }
  function _tmConeDown(ctx, cx, y0, w, h, fill) {
    ctx.beginPath(); ctx.moveTo(cx-w/2, y0); ctx.lineTo(cx+w/2, y0); ctx.lineTo(cx, y0+h); ctx.closePath();
    ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke();
  }
  function _tmSphere(ctx, cx, cy, r, fill) {
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
    ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke();
  }
  function _tmX(ctx, cx, cy, r, col) {
    ctx.strokeStyle = col; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(cx-r, cy-r); ctx.lineTo(cx+r, cy+r); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(cx+r, cy-r); ctx.lineTo(cx-r, cy+r); ctx.stroke();
  }

  // ── 3D helpers ───────────────────────────────────────────────────────────
  function _h2r(hex) {
    hex = (hex || '').replace('#','');
    if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
    if (hex.length < 6) return [120, 144, 156];   // fallback gris neutro — evita NaN
    const r = parseInt(hex.slice(0,2),16);
    const g = parseInt(hex.slice(2,4),16);
    const b = parseInt(hex.slice(4,6),16);
    return [isNaN(r)?120:r, isNaN(g)?144:g, isNaN(b)?156:b];
  }
  function _lighten(hex, f=0.45) {
    const [r,g,b] = _h2r(hex);
    return `rgb(${Math.min(255,Math.round(r+(255-r)*f))},${Math.min(255,Math.round(g+(255-g)*f))},${Math.min(255,Math.round(b+(255-b)*f))})`;
  }
  function _darken(hex, f=0.45) {
    const [r,g,b] = _h2r(hex);
    return `rgb(${Math.round(r*(1-f))},${Math.round(g*(1-f))},${Math.round(b*(1-f))})`;
  }
  function _cylGrad(ctx, x, w, yMid, col) {
    const g = ctx.createLinearGradient(x, yMid, x+w, yMid);
    g.addColorStop(0,    _lighten(col,0.58)); g.addColorStop(0.22, _lighten(col,0.18));
    g.addColorStop(0.55, col);               g.addColorStop(0.82, _darken(col,0.32));
    g.addColorStop(1,    _darken(col,0.55)); return g;
  }
  function _coneGrad(ctx, x, w, yMid, col) {
    const g = ctx.createLinearGradient(x, yMid, x+w, yMid);
    g.addColorStop(0,    _lighten(col,0.65)); g.addColorStop(0.20, _lighten(col,0.20));
    g.addColorStop(0.55, col);               g.addColorStop(1,    _darken(col,0.60)); return g;
  }
  function _fillBands3D(ctx, pathFn, bx, by, bw, bh, css, gradFn) {
    ctx.save(); pathFn(ctx); ctx.clip();
    if (!css.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx,by,bw,bh); }
    else {
      const bandH = bh / css.length;
      css.forEach((col,i) => {
        const y0 = by + i*bandH;
        ctx.fillStyle = gradFn(ctx, bx, bw, y0+bandH/2, col);
        ctx.fillRect(bx, y0, bw, bandH+1);
      });
    }
    ctx.restore();
  }

  function _ialaLateralColours(catlam, region) {
    const ialaB = (region||'B') === 'B';
    const G=4, R=3;
    const port = ialaB ? G : R, stbd = ialaB ? R : G;
    if (catlam===1) return [port]; if (catlam===2) return [stbd];
    if (catlam===3) return [port,stbd,port]; if (catlam===4) return [stbd,port,stbd];
    return [];
  }

  // ── S-52 light flare ─────────────────────────────────────────────────────
  function _drawLightFlare(ctx, cx, y, col) {
    const MAG='#ff00aa', len=8;
    ctx.save(); ctx.translate(cx, y);
    ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(len*0.95,-len*0.30); ctx.lineTo(len*0.30,-len*0.95); ctx.closePath();
    ctx.fillStyle = MAG; ctx.fill();
    ctx.strokeStyle='#fff'; ctx.lineWidth=0.5; ctx.stroke();
    ctx.beginPath(); ctx.arc(0,0,1.5,0,Math.PI*2); ctx.fillStyle=MAG; ctx.fill();
    if (col && col!==MAG) {
      ctx.beginPath(); ctx.arc(len*0.62,-len*0.62,1.8,0,Math.PI*2);
      ctx.fillStyle=col; ctx.fill();
      ctx.strokeStyle='#fff'; ctx.lineWidth=0.4; ctx.stroke();
    }
    ctx.restore();
  }

  // ── 3D buoy canvas ───────────────────────────────────────────────────────
  function _encBuoyCanvas(opts) {
    const sz = opts.sz || 52;
    const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz/2;
    let cc = (opts.colours||[]).slice();
    if (!cc.length && opts.catlam) cc = _ialaLateralColours(opts.catlam, opts.region);
    const css = cc.map(_s57css);
    const c1 = css[0] || '#78909c';
    const wlY=sz*0.92, bBot=sz*0.88, bTop=sz*0.52, bW=sz*0.52, bH=bBot-bTop;
    const eRy=bH*0.095;
    const _tmRoom=sz*0.52*0.40;
    const stTop = opts.hasLight ? Math.max(sz*0.20,_tmRoom+sz*0.03) : Math.max(sz*0.24,_tmRoom+sz*0.03);
    const glowCol = c1 ? _lighten(c1,0.55) : 'rgba(255,255,255,0.7)';
    const outln = (lw=0.9) => { ctx.shadowBlur=0; ctx.strokeStyle='rgba(0,0,0,0.75)'; ctx.lineWidth=lw; ctx.stroke(); };

    const drawCylinder = (x, topY, w, h, colList) => {
      const ery=h*0.095;
      _fillBands3D(ctx, cc=>{ cc.beginPath(); cc.rect(x,topY,w,h); }, x, topY, w, h, colList, _cylGrad);
      ctx.shadowBlur=5; ctx.shadowColor=glowCol;
      ctx.strokeStyle='rgba(255,255,255,0.45)'; ctx.lineWidth=1.0; ctx.strokeRect(x,topY,w,h);
      ctx.shadowBlur=0; ctx.strokeStyle='rgba(0,0,0,0.55)'; ctx.lineWidth=0.6; ctx.strokeRect(x,topY,w,h);
      ctx.beginPath(); ctx.ellipse(x+w/2,topY+h,w/2,ery,0,0,Math.PI*2);
      ctx.fillStyle=_darken(colList[colList.length-1]||c1,0.5); ctx.fill(); outln(0.5);
      const tg=ctx.createRadialGradient(x+w*0.3,topY,1,x+w/2,topY,w/2);
      tg.addColorStop(0,'rgba(255,255,255,0.80)'); tg.addColorStop(0.5,_lighten(colList[0]||c1,0.25)); tg.addColorStop(1,colList[0]||c1);
      ctx.beginPath(); ctx.ellipse(x+w/2,topY,w/2,ery,0,0,Math.PI*2);
      ctx.fillStyle=tg; ctx.fill(); outln(0.5);
    };
    const drawCone = (cx2, apexY, baseY, w, colList) => {
      const ery=(baseY-apexY)*0.10, bx=cx2-w/2;
      _fillBands3D(ctx, cc=>{ cc.beginPath(); cc.moveTo(cx2,apexY); cc.lineTo(cx2+w/2,baseY); cc.lineTo(cx2-w/2,baseY); cc.closePath(); },
        bx,apexY,w,baseY-apexY,colList,_coneGrad);
      ctx.beginPath(); ctx.moveTo(cx2,apexY); ctx.lineTo(cx2+w/2,baseY); ctx.lineTo(cx2-w/2,baseY); ctx.closePath();
      ctx.shadowBlur=5; ctx.shadowColor=glowCol; ctx.strokeStyle='rgba(255,255,255,0.40)'; ctx.lineWidth=1.0; ctx.stroke(); ctx.shadowBlur=0;
      ctx.beginPath(); ctx.moveTo(cx2,apexY); ctx.lineTo(cx2+w/2,baseY); ctx.lineTo(cx2-w/2,baseY); ctx.closePath(); outln(0.9);
      ctx.beginPath(); ctx.ellipse(cx2,baseY,w/2,ery,0,0,Math.PI*2);
      ctx.fillStyle=_darken(colList[colList.length-1]||c1,0.40); ctx.fill(); outln(0.5);
    };
    const drawSphere = (cx2, cy2, r, colList) => {
      ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); ctx.fillStyle=colList[0]||'#78909c'; ctx.fill();
      ctx.save(); ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); ctx.clip();
      const rg=ctx.createRadialGradient(cx2-r*0.38,cy2-r*0.38,r*0.04,cx2,cy2,r);
      rg.addColorStop(0,'rgba(255,255,255,0.72)'); rg.addColorStop(0.30,'rgba(255,255,255,0.18)');
      rg.addColorStop(0.65,'rgba(0,0,0,0.00)'); rg.addColorStop(1,'rgba(0,0,0,0.42)');
      ctx.fillStyle=rg; ctx.fillRect(cx2-r,cy2-r,r*2,r*2); ctx.restore();
      ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); outln(0.8);
    };
    const drawTopmarkCan  = (tx,ty,w,col) => { const h=w; drawCylinder(tx-w/2,ty-h,w,h,[col]); };
    const drawTopmarkCone = (tx,ty,w,col) => { const h=w*0.9; drawCone(tx,ty-h,ty,w,[col]); };
    const drawTopmarkConeSmall = (tx,ty,w,col) => { const h=w*0.7; drawCone(tx,ty-h,ty,w,[col]); };

    const bs=opts.boyshp, cl=opts.catlam;
    const isCan=bs===2||(!bs&&(cl===1||cl===3));
    const isCone=bs===1||(!bs&&(cl===2||cl===4));
    const isSph=bs===3, isSpar=bs===5, isBar=bs===6, isSuper=bs===7;

    if (isCan) {
      drawCylinder(cx-bW/2,bTop,bW,bH,css.length?css:[c1]);
      _st(ctx,cx,bTop-eRy,stTop); drawTopmarkCan(cx,stTop,bW*0.42,c1);
    } else if (isCone) {
      drawCone(cx,bTop,bBot,bW,css.length?css:[c1]);
      _st(ctx,cx,bTop,stTop); drawTopmarkCone(cx,stTop,bW*0.44,c1);
    } else if (isSph) {
      const r=bH*0.44, bcy=bTop+r; drawSphere(cx,bcy,r,css.length?css:[c1]);
      _st(ctx,cx,bcy-r,stTop); _tmSphere(ctx,cx,stTop-sz*0.09,sz*0.09,c1);
    } else if (isSpar) {
      const sw=bW*0.24, sparTop=bTop-bH*0.40;
      drawCylinder(cx-sw/2,sparTop,sw,bH*1.40,css.length?css:[c1]);
      if (cl===1||cl===3) drawTopmarkCan(cx,sparTop-2,sw*1.4,c1);
      else if (cl===2||cl===4) drawTopmarkCone(cx,sparTop-2,sw*1.4,c1);
      else drawTopmarkConeSmall(cx,sparTop-2,sw*1.4,c1);
    } else {
      // PILLAR (default)
      const pw=bW*0.48;
      drawCylinder(cx-pw/2,bTop+bH*0.4,pw,bH*0.6,css.length?css:[c1]);
      drawCylinder(cx-pw*0.35,bTop,pw*0.7,bH*0.4,[_darken(c1,0.08)]);
      _st(ctx,cx,bTop,stTop);
      if (cl===1||cl===3) drawTopmarkCan(cx,stTop,bW*0.42,c1);
      else if (cl===2||cl===4) drawTopmarkCone(cx,stTop,bW*0.44,c1);
      else drawTopmarkConeSmall(cx,stTop,bW*0.50,c1);
    }
    _wl(ctx,cx,wlY,bW*0.68);
    if (opts.hasLight) _drawLightFlare(ctx,cx,stTop+sz*0.02,css[0]||'#ffffff');
    return c;
  }

  // ── Cardinal buoy canvas ─────────────────────────────────────────────────
  function _encCardinalCanvas(quadrant, sz=40) {
    const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
    const BLK='#111111', YEL='#f9a825';
    const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.20;
    const bW=sz*0.44, bH=bBot-bTop;
    const bands = {N:[BLK,YEL],S:[YEL,BLK],E:[BLK,YEL,BLK],W:[YEL,BLK,YEL]}[quadrant]||[BLK,YEL];
    const bandH=bH/bands.length;
    bands.forEach((col,i)=>{ ctx.fillStyle=col; ctx.fillRect(cx-bW/2,bTop+i*bandH,bW,bandH); });
    ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
    _st(ctx,cx,bTop,sTop);
    const cw=sz*0.36, ch=sz*0.14, t1=sTop-ch*0.2, t2=t1-ch-sz*0.01;
    if (quadrant==='N') { _tmConeUp(ctx,cx,t2,cw,ch,BLK); _tmConeUp(ctx,cx,t1,cw,ch,BLK); }
    else if (quadrant==='S') { _tmConeDown(ctx,cx,t2,cw,ch,BLK); _tmConeDown(ctx,cx,t1,cw,ch,BLK); }
    else if (quadrant==='E') { _tmConeUp(ctx,cx,t2,cw,ch,BLK); _tmConeDown(ctx,cx,t1,cw,ch,BLK); }
    else { _tmConeDown(ctx,cx,t2,cw,ch,BLK); _tmConeUp(ctx,cx,t1,cw,ch,BLK); }
    _wl(ctx,cx,wlY,bW*0.65);
    return c;
  }

  // ── Isolated danger ──────────────────────────────────────────────────────
  function _encIsdCanvas(sz=40) {
    const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
    const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28;
    const bW=sz*0.44, bH=bBot-bTop;
    ctx.fillStyle='#111'; ctx.fillRect(cx-bW/2,bTop,bW,bH);
    ctx.fillStyle='#e53935'; ctx.fillRect(cx-bW/2,bTop+bH*0.35,bW,bH*0.30);
    ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
    _st(ctx,cx,bTop,sTop);
    const sr=sz*0.07; _tmSphere(ctx,cx-sr*1.2,sTop-sr,sr,'#111'); _tmSphere(ctx,cx+sr*1.2,sTop-sr,sr,'#111');
    _wl(ctx,cx,wlY,bW*0.65); return c;
  }

  // ── Safe water ───────────────────────────────────────────────────────────
  function _encSawCanvas(sz=40) {
    const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
    const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28;
    const bW=sz*0.44, bH=bBot-bTop;
    ctx.save(); ctx.beginPath(); ctx.rect(cx-bW/2,bTop,bW,bH); ctx.clip();
    for (let i=0;i<4;i++) { ctx.fillStyle=i%2===0?'#e53935':'#ffffff'; ctx.fillRect(cx-bW/2+i*bW/4,bTop,bW/4,bH); }
    ctx.restore(); ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
    _st(ctx,cx,bTop,sTop); _tmSphere(ctx,cx,sTop-sz*0.07,sz*0.07,'#e53935');
    _wl(ctx,cx,wlY,bW*0.65); return c;
  }

  // ── Special buoy ─────────────────────────────────────────────────────────
  function _encSppCanvas(sz=40) {
    const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
    const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28;
    const bW=sz*0.44, bH=bBot-bTop;
    ctx.fillStyle='#f9a825'; ctx.fillRect(cx-bW/2,bTop,bW,bH);
    ctx.strokeStyle='#555'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
    _st(ctx,cx,bTop,sTop); _tmX(ctx,cx,sTop-sz*0.09,sz*0.10,'#f9a825');
    _wl(ctx,cx,wlY,bW*0.65); return c;
  }

  // ── Beacon ───────────────────────────────────────────────────────────────
  function _encBeaconCanvas(colours, catlam, region, sz=36) {
    const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
    let cc=(colours||[]).slice();
    if (!cc.length&&catlam) cc=_ialaLateralColours(catlam, region);
    const css=cc.map(_s57css); const c1=css[0]||'#78909c';
    const ialaB=(region||'B')==='B';
    const groundY=sz*0.92, poleTopY=sz*0.04, poleW=sz*0.055;
    const bodyW=sz*0.28, bodyTopY=sz*0.48, bodyBotY=groundY;
    const tmW=sz*0.36, tmBotY=sz*0.27, tmTopY=sz*0.05;

    // Ground shadow
    ctx.beginPath(); ctx.ellipse(cx,groundY,sz*0.12,sz*0.020,0,0,Math.PI*2);
    ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fill();

    // Pole
    const poleGrd=ctx.createLinearGradient(cx-poleW,0,cx+poleW,0);
    poleGrd.addColorStop(0,'#999'); poleGrd.addColorStop(0.4,'#ddd'); poleGrd.addColorStop(1,'#777');
    ctx.fillStyle=poleGrd; ctx.fillRect(cx-poleW/2,poleTopY,poleW,groundY-poleTopY);

    // Body
    const bodyGrd=ctx.createLinearGradient(cx-bodyW/2,0,cx+bodyW/2,0);
    bodyGrd.addColorStop(0,_lighten(c1,0.35)); bodyGrd.addColorStop(0.45,c1); bodyGrd.addColorStop(1,_darken(c1,0.28));
    ctx.fillStyle=bodyGrd; ctx.fillRect(cx-bodyW/2,bodyTopY,bodyW,bodyBotY-bodyTopY);
    ctx.strokeStyle='rgba(0,0,0,0.65)'; ctx.lineWidth=0.9;
    ctx.strokeRect(cx-bodyW/2,bodyTopY,bodyW,bodyBotY-bodyTopY);

    // Topmark: PORT = square, STBD = triangle
    const isPort=catlam===1||catlam===3||(ialaB&&cc[0]===4)||(!ialaB&&cc[0]===3);
    if (isPort) {
      ctx.fillStyle=c1; ctx.fillRect(cx-tmW/2,tmTopY,tmW,tmBotY-tmTopY);
      ctx.strokeStyle='#111'; ctx.lineWidth=0.8; ctx.strokeRect(cx-tmW/2,tmTopY,tmW,tmBotY-tmTopY);
    } else {
      ctx.beginPath(); ctx.moveTo(cx,tmTopY); ctx.lineTo(cx+tmW/2,tmBotY); ctx.lineTo(cx-tmW/2,tmBotY); ctx.closePath();
      ctx.fillStyle=c1; ctx.fill(); ctx.strokeStyle='#111'; ctx.lineWidth=0.8; ctx.stroke();
    }
    return c;
  }

  // ── Lighthouse / light symbol (S-52 style) ───────────────────────────────
  function _ialaLight(colourCode) {
    const W=26, H=46;
    const c=document.createElement('canvas'); c.width=W; c.height=H;
    const ctx=c.getContext('2d'); const cx=W/2;
    let col='#ffffff';
    if (colourCode===3) col='#dd1111';
    else if (colourCode===4) col='#00aa00';
    else if (colourCode===6) col='#ffcc00';
    const bW=14, bH=17, bTop=H*0.50, bBot=bTop+bH;
    ctx.beginPath(); ctx.rect(cx-bW/2,bTop,bW,bH);
    ctx.fillStyle=col; ctx.fill(); ctx.strokeStyle='#111'; ctx.lineWidth=1.2; ctx.stroke();
    // 5-point star
    const starX=cx, starY=bTop+bH/2, Ro=4.5, Ri=1.85;
    ctx.beginPath();
    for (let i=0;i<10;i++) {
      const ang=(i*Math.PI/5)-Math.PI/2, rr=(i%2===0)?Ro:Ri;
      const px=starX+Math.cos(ang)*rr, py=starY+Math.sin(ang)*rr;
      i===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
    }
    ctx.closePath(); ctx.fillStyle=(col==='#ffffff')?'#222':'#fff'; ctx.fill();
    // Magenta light flash
    const PURP='#8800cc', tearAng=20*Math.PI/180, tearLen=18;
    ctx.save(); ctx.translate(cx+bW/2-1,bTop+1); ctx.rotate(tearAng);
    ctx.beginPath(); ctx.moveTo(0,0);
    ctx.bezierCurveTo(-3.8,-5,-3.8,-11,0,-tearLen);
    ctx.bezierCurveTo(3.8,-11,3.8,-5,0,0);
    ctx.closePath(); ctx.fillStyle=PURP; ctx.globalAlpha=0.88; ctx.fill(); ctx.globalAlpha=1.0;
    ctx.strokeStyle='rgba(90,0,140,0.5)'; ctx.lineWidth=0.5; ctx.stroke(); ctx.restore();
    // Anchor dot
    ctx.beginPath(); ctx.arc(cx,H-2,2.2,0,Math.PI*2); ctx.fillStyle='#fff'; ctx.fill();
    ctx.beginPath(); ctx.arc(cx,H-2,1.0,0,Math.PI*2); ctx.fillStyle='#111'; ctx.fill();
    return c;
  }

  // ── Light point (infers buoy shape from colour+region) ───────────────────
  function _encLightCanvas(colours, region, sz=32) {
    const raw=colours[0];
    const ialaB=(region||'B')==='B';
    // White/magenta lights → simple lighthouse symbol
    if (!raw || raw===1 || raw===12) return _ialaLight(raw||1);
    // Coloured light → infer lateral buoy
    const isPort = (ialaB&&raw===4)||(!ialaB&&raw===3);
    const isStbd = (ialaB&&raw===3)||(!ialaB&&raw===4);
    if (isPort||isStbd) {
      const catlam = isPort?1:2;
      return _encBuoyCanvas({ colours, boyshp: isPort?2:1, catlam, region, sz, hasLight:true });
    }
    if (raw===6) return _encSppCanvas(sz); // yellow = special
    return _ialaLight(raw);
  }

  // ── SVG icon library — idénticos a AR ECDIS (anchor=bottom) ─────────────
  var _svgSrc = (function () {
    // Punto de posición S-52: pequeño, sutil, marca la posición exacta de la ayuda
    var D = function (cx, cy) {
      return '<circle cx="' + cx + '" cy="' + cy + '" r="1.8" fill="rgba(10,10,18,0.72)"/>';
    };
    var icons = {
      // ── Lateral IALA-B ──────────────────────────────────────────────────
      'boylat-port-b': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="30" viewBox="0 0 20 30">' +
        '<ellipse cx="10" cy="7" rx="6" ry="2.5" fill="#33dd33" stroke="#111" stroke-width="1"/>' +
        '<rect x="4" y="7" width="12" height="14" rx="2" fill="#009900" stroke="#111" stroke-width="1"/>' +
        '<ellipse cx="10" cy="21" rx="6" ry="2" fill="#007700" stroke="#111" stroke-width="1"/>' +
        D(10,27) + '</svg>',
      'boylat-stbd-b': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="28" viewBox="0 0 20 28">' +
        '<polygon points="10,2 18,21 2,21" fill="#cc0000" stroke="#111" stroke-width="1"/>' +
        D(10,25) + '</svg>',
      // ── Lateral IALA-A ──────────────────────────────────────────────────
      'boylat-port': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="30" viewBox="0 0 20 30">' +
        '<ellipse cx="10" cy="7" rx="6" ry="2.5" fill="#ee2222" stroke="#111" stroke-width="1"/>' +
        '<rect x="4" y="7" width="12" height="14" rx="2" fill="#cc0000" stroke="#111" stroke-width="1"/>' +
        '<ellipse cx="10" cy="21" rx="6" ry="2" fill="#aa0000" stroke="#111" stroke-width="1"/>' +
        D(10,27) + '</svg>',
      'boylat-stbd': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="28" viewBox="0 0 20 28">' +
        '<polygon points="10,2 18,21 2,21" fill="#009900" stroke="#111" stroke-width="1"/>' +
        D(10,25) + '</svg>',
      // ── Balizas IALA-B ──────────────────────────────────────────────────
      'bcnlat-port-b': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="48" viewBox="0 0 22 48">' +
        '<defs><linearGradient id="bpb-pole" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#bbb"/><stop offset=".45" stop-color="#eee"/><stop offset="1" stop-color="#888"/>' +
        '</linearGradient><linearGradient id="bpb-body" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#66ee66"/><stop offset=".45" stop-color="#009900"/><stop offset="1" stop-color="#006600"/>' +
        '</linearGradient></defs>' +
        '<ellipse cx="11" cy="42" rx="5" ry="1.2" fill="rgba(0,0,0,0.28)"/>' +
        '<rect x="10" y="2" width="2" height="39" fill="url(#bpb-pole)" rx="1"/>' +
        '<rect x="8.5" y="24" width="5" height="16" fill="url(#bpb-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        '<rect x="7" y="4" width="8" height="12" fill="url(#bpb-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        D(11,45) + '</svg>',
      'bcnlat-stbd-b': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="48" viewBox="0 0 22 48">' +
        '<defs><linearGradient id="bsb-pole" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#bbb"/><stop offset=".45" stop-color="#eee"/><stop offset="1" stop-color="#888"/>' +
        '</linearGradient><linearGradient id="bsb-body" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#ff5555"/><stop offset=".45" stop-color="#cc0000"/><stop offset="1" stop-color="#880000"/>' +
        '</linearGradient></defs>' +
        '<ellipse cx="11" cy="42" rx="5" ry="1.2" fill="rgba(0,0,0,0.28)"/>' +
        '<rect x="10" y="2" width="2" height="39" fill="url(#bsb-pole)" rx="1"/>' +
        '<rect x="8.5" y="24" width="5" height="16" fill="url(#bsb-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        '<polygon points="11,4 15,16 7,16" fill="url(#bsb-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        D(11,45) + '</svg>',
      // ── Balizas IALA-A ──────────────────────────────────────────────────
      'bcnlat-port': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="48" viewBox="0 0 22 48">' +
        '<defs><linearGradient id="bpa-pole" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#bbb"/><stop offset=".45" stop-color="#eee"/><stop offset="1" stop-color="#888"/>' +
        '</linearGradient><linearGradient id="bpa-body" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#ff5555"/><stop offset=".45" stop-color="#cc0000"/><stop offset="1" stop-color="#880000"/>' +
        '</linearGradient></defs>' +
        '<ellipse cx="11" cy="42" rx="5" ry="1.2" fill="rgba(0,0,0,0.28)"/>' +
        '<rect x="10" y="2" width="2" height="39" fill="url(#bpa-pole)" rx="1"/>' +
        '<rect x="8.5" y="24" width="5" height="16" fill="url(#bpa-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        '<rect x="7" y="4" width="8" height="12" fill="url(#bpa-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        D(11,45) + '</svg>',
      'bcnlat-stbd': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="48" viewBox="0 0 22 48">' +
        '<defs><linearGradient id="bsa-pole" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#bbb"/><stop offset=".45" stop-color="#eee"/><stop offset="1" stop-color="#888"/>' +
        '</linearGradient><linearGradient id="bsa-body" x1="0" y1="0" x2="1" y2="0">' +
        '<stop offset="0" stop-color="#66ee66"/><stop offset=".45" stop-color="#009900"/><stop offset="1" stop-color="#006600"/>' +
        '</linearGradient></defs>' +
        '<ellipse cx="11" cy="42" rx="5" ry="1.2" fill="rgba(0,0,0,0.28)"/>' +
        '<rect x="10" y="2" width="2" height="39" fill="url(#bsa-pole)" rx="1"/>' +
        '<rect x="8.5" y="24" width="5" height="16" fill="url(#bsa-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        '<polygon points="11,4 15,16 7,16" fill="url(#bsa-body)" stroke="rgba(0,0,0,.55)" stroke-width=".9"/>' +
        D(11,45) + '</svg>',
      // ── Cardinales ──────────────────────────────────────────────────────
      'boycar-n': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="40" viewBox="0 0 20 40">' +
        '<polygon points="10,0 14,7 6,7" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<polygon points="10,8 14,15 6,15" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<line x1="10" y1="15" x2="10" y2="19" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="6" y="19" width="8" height="6" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<rect x="6" y="25" width="8" height="6" fill="#ffdd00" stroke="#111" stroke-width=".8"/>' +
        D(10,37) + '</svg>',
      'boycar-s': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="40" viewBox="0 0 20 40">' +
        '<polygon points="6,0 14,0 10,7" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<polygon points="6,8 14,8 10,15" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<line x1="10" y1="15" x2="10" y2="19" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="6" y="19" width="8" height="6" fill="#ffdd00" stroke="#111" stroke-width=".8"/>' +
        '<rect x="6" y="25" width="8" height="6" fill="#111" stroke="#111" stroke-width=".5"/>' +
        D(10,37) + '</svg>',
      'boycar-e': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="42" viewBox="0 0 20 42">' +
        '<polygon points="10,0 16,8 10,16 4,8" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<line x1="10" y1="16" x2="10" y2="20" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="6" y="20" width="8" height="5" fill="#111"/>' +
        '<rect x="6" y="25" width="8" height="5" fill="#ffdd00" stroke="#111" stroke-width=".8"/>' +
        '<rect x="6" y="30" width="8" height="5" fill="#111"/>' +
        D(10,39) + '</svg>',
      'boycar-w': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="42" viewBox="0 0 20 42">' +
        '<polygon points="4,0 16,0 10,8" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<polygon points="4,16 16,16 10,8" fill="#111" stroke="#111" stroke-width=".5"/>' +
        '<line x1="10" y1="16" x2="10" y2="20" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="6" y="20" width="8" height="5" fill="#ffdd00" stroke="#111" stroke-width=".8"/>' +
        '<rect x="6" y="25" width="8" height="5" fill="#111"/>' +
        '<rect x="6" y="30" width="8" height="5" fill="#ffdd00" stroke="#111" stroke-width=".8"/>' +
        D(10,39) + '</svg>',
      // ── Especiales ──────────────────────────────────────────────────────
      'boyisd': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="44" viewBox="0 0 22 44">' +
        '<circle cx="11" cy="4" r="4" fill="#111"/><circle cx="11" cy="12" r="4" fill="#111"/>' +
        '<line x1="11" y1="16" x2="11" y2="20" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="5" y="20" width="12" height="5" fill="#111"/>' +
        '<rect x="5" y="25" width="12" height="5" fill="#cc0000"/>' +
        '<rect x="5" y="30" width="12" height="5" fill="#111"/>' +
        D(11,41) + '</svg>',
      'boysaw': '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="38" viewBox="0 0 18 38">' +
        '<circle cx="9" cy="4" r="4" fill="#cc0000" stroke="#111"/>' +
        '<line x1="9" y1="8" x2="9" y2="12" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="3" y="12" width="12" height="5" fill="#cc0000" stroke="#111" stroke-width=".5"/>' +
        '<rect x="3" y="17" width="12" height="5" fill="white" stroke="#111" stroke-width=".5"/>' +
        '<rect x="3" y="22" width="12" height="5" fill="#cc0000" stroke="#111" stroke-width=".5"/>' +
        '<rect x="3" y="27" width="12" height="3" fill="white" stroke="#111" stroke-width=".5"/>' +
        D(9,35) + '</svg>',
      'boyspp': '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="32" viewBox="0 0 20 32">' +
        '<line x1="6" y1="1" x2="14" y2="9" stroke="#cc9900" stroke-width="2.5" stroke-linecap="round"/>' +
        '<line x1="14" y1="1" x2="6" y2="9" stroke="#cc9900" stroke-width="2.5" stroke-linecap="round"/>' +
        '<line x1="10" y1="9" x2="10" y2="13" stroke="#555" stroke-width="1.5"/>' +
        '<rect x="4" y="13" width="12" height="12" rx="2" fill="#ffcc00" stroke="#111" stroke-width="1"/>' +
        D(10,29) + '</svg>',
      // ── Peligros S-52 ────────────────────────────────────────────────────
      // Wreck (WRECKS) — silueta de barco hundido, magenta S-52, contorno blanco para visibilidad
      'wrecks': '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="26" viewBox="0 0 28 26">' +
        '<ellipse cx="14" cy="24.5" rx="8" ry="1.2" fill="rgba(0,0,0,0.30)"/>' +
        '<line x1="14" y1="1" x2="14" y2="9" stroke="white" stroke-width="3.2" stroke-linecap="round"/>' +
        '<line x1="14" y1="1" x2="14" y2="9" stroke="#cc1133" stroke-width="2.2" stroke-linecap="round"/>' +
        '<line x1="7" y1="5" x2="21" y2="5" stroke="white" stroke-width="3.0" stroke-linecap="round"/>' +
        '<line x1="7" y1="5" x2="21" y2="5" stroke="#cc1133" stroke-width="2.0" stroke-linecap="round"/>' +
        '<line x1="2" y1="9" x2="26" y2="9" stroke="white" stroke-width="3.2" stroke-linecap="round"/>' +
        '<line x1="2" y1="9" x2="26" y2="9" stroke="#cc1133" stroke-width="2.2" stroke-linecap="round"/>' +
        '<path d="M4,9 L4,17 Q14,22 24,17 L24,9 Z" stroke="white" stroke-width="2.8" fill="none" stroke-linejoin="round"/>' +
        '<path d="M4,9 L4,17 Q14,22 24,17 L24,9 Z" stroke="#cc1133" stroke-width="1.8" fill="rgba(204,17,51,0.18)" stroke-linejoin="round"/>' +
        '</svg>',
      // Roca sumergida (UWTROC) — asterisco de 8 puntas, naranja vivo S-52
      'uwtroc': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="-11 -11 22 22">' +
        '<line x1="0" y1="-9" x2="0" y2="9" stroke="white" stroke-width="3.4" stroke-linecap="round"/>' +
        '<line x1="-9" y1="0" x2="9" y2="0" stroke="white" stroke-width="3.4" stroke-linecap="round"/>' +
        '<line x1="-6.4" y1="-6.4" x2="6.4" y2="6.4" stroke="white" stroke-width="3.0" stroke-linecap="round"/>' +
        '<line x1="6.4" y1="-6.4" x2="-6.4" y2="6.4" stroke="white" stroke-width="3.0" stroke-linecap="round"/>' +
        '<line x1="0" y1="-9" x2="0" y2="9" stroke="#ee7700" stroke-width="2.2" stroke-linecap="round"/>' +
        '<line x1="-9" y1="0" x2="9" y2="0" stroke="#ee7700" stroke-width="2.2" stroke-linecap="round"/>' +
        '<line x1="-6.4" y1="-6.4" x2="6.4" y2="6.4" stroke="#ee7700" stroke-width="1.8" stroke-linecap="round"/>' +
        '<line x1="6.4" y1="-6.4" x2="-6.4" y2="6.4" stroke="#ee7700" stroke-width="1.8" stroke-linecap="round"/>' +
        '<circle cx="0" cy="0" r="2.8" fill="white"/>' +
        '<circle cx="0" cy="0" r="1.8" fill="#ee7700"/>' +
        '</svg>',
      // Obstrucción (OBSTRN) — rombo con X, amarillo-naranja S-52
      'obstrn': '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="-11 -11 22 22">' +
        '<polygon points="0,-9 8,0 0,9 -8,0" stroke="white" stroke-width="2.8" fill="none" stroke-linejoin="round"/>' +
        '<polygon points="0,-9 8,0 0,9 -8,0" stroke="#dd8800" stroke-width="1.8" fill="rgba(221,136,0,0.15)" stroke-linejoin="round"/>' +
        '<line x1="-4.5" y1="-4.5" x2="4.5" y2="4.5" stroke="white" stroke-width="2.6" stroke-linecap="round"/>' +
        '<line x1="4.5" y1="-4.5" x2="-4.5" y2="4.5" stroke="white" stroke-width="2.6" stroke-linecap="round"/>' +
        '<line x1="-4.5" y1="-4.5" x2="4.5" y2="4.5" stroke="#dd8800" stroke-width="1.6" stroke-linecap="round"/>' +
        '<line x1="4.5" y1="-4.5" x2="-4.5" y2="4.5" stroke="#dd8800" stroke-width="1.6" stroke-linecap="round"/>' +
        '</svg>',
      // ── Faro ────────────────────────────────────────────────────────────
      'lighthouse': '<svg xmlns="http://www.w3.org/2000/svg" width="44" height="62" viewBox="0 0 44 62">' +
        '<defs><radialGradient id="lhalo" cx="0.5" cy="0.5" r="0.5">' +
        '<stop offset="0" stop-color="rgba(255,240,120,0.70)"/><stop offset="1" stop-color="rgba(255,240,120,0)"/>' +
        '</radialGradient></defs>' +
        '<ellipse cx="22" cy="60" rx="7" ry="1.4" fill="rgba(0,0,0,0.30)"/>' +
        '<line x1="8" y1="59" x2="16" y2="31" stroke="white" stroke-width="2.2" stroke-linecap="round"/>' +
        '<line x1="36" y1="59" x2="28" y2="31" stroke="white" stroke-width="2.2" stroke-linecap="round"/>' +
        '<line x1="10" y1="47" x2="28" y2="31" stroke="white" stroke-width="1.4"/>' +
        '<line x1="34" y1="47" x2="16" y2="31" stroke="white" stroke-width="1.4"/>' +
        '<line x1="6" y1="59" x2="38" y2="59" stroke="white" stroke-width="2.0"/>' +
        '<rect x="20" y="27" width="4" height="5" fill="white"/>' +
        '<ellipse cx="22" cy="20" rx="13" ry="12" fill="url(#lhalo)"/>' +
        '<rect x="12" y="25" width="20" height="2" rx="1" fill="white" stroke="rgba(0,0,0,.40)" stroke-width=".5"/>' +
        '<rect x="16" y="16" width="12" height="10" rx="1" fill="white" stroke="rgba(0,0,0,.40)" stroke-width=".8"/>' +
        '<path d="M16,16 Q22,7 28,16 Z" fill="white" stroke="rgba(0,0,0,.35)" stroke-width=".8"/>' +
        '<line x1="22" y1="7" x2="22" y2="3" stroke="white" stroke-width="1.2"/>' +
        '<circle cx="22" cy="3" r="1.2" fill="#ffee44"/>' +
        '<circle cx="22" cy="21" r="2.5" fill="#ffe680" opacity=".85"/>' +
        '<circle cx="22" cy="60.5" r="2" fill="white"/><circle cx="22" cy="60.5" r="1.1" fill="black"/></svg>',
    };
    var out = {};
    Object.keys(icons).forEach(function (k) {
      out[k] = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(icons[k]);
    });
    return out;
  })();

  // ── ENC feature style — SIN etiquetas estáticas (solo en hover/click) ─────
  // Las etiquetas se muestran únicamente en tooltip al hacer hover.
  function encStyle(feature) {
    var aidType = feature.get('aid_type') || '';
    var layer   = (feature.get('layer') || '').toUpperCase();
    var region  = feature.get('cell_region') || 'B';
    var isBcn   = layer.startsWith('BCN');
    var ialaB   = region !== 'A';
    var colours = feature.get('colours') || [];

    var iconKey = null;

    switch (aidType) {
      case 'LATERAL_PORT': case 'LATERAL_PREF_STBD': case 'LATERAL_UNKNOWN':
        iconKey = isBcn ? (ialaB ? 'bcnlat-port-b' : 'bcnlat-port')
                        : (ialaB ? 'boylat-port-b'  : 'boylat-port');
        break;
      case 'LATERAL_STBD': case 'LATERAL_PREF_PORT':
        iconKey = isBcn ? (ialaB ? 'bcnlat-stbd-b' : 'bcnlat-stbd')
                        : (ialaB ? 'boylat-stbd-b'  : 'boylat-stbd');
        break;
      case 'CARDINAL_N':       iconKey = 'boycar-n'; break;
      case 'CARDINAL_S':       iconKey = 'boycar-s'; break;
      case 'CARDINAL_E':       iconKey = 'boycar-e'; break;
      case 'CARDINAL_W':       iconKey = 'boycar-w'; break;
      case 'CARDINAL_UNKNOWN': iconKey = 'boycar-n'; break;
      case 'ISOLATED_DANGER':  iconKey = 'boyisd';   break;
      case 'SAFE_WATER':       iconKey = 'boysaw';   break;
      case 'SPECIAL':          iconKey = 'boyspp';   break;
      // Genéricos — fallback a símbolo por defecto del tipo
      case 'BEACON_GENERIC':
        iconKey = ialaB ? 'bcnlat-port-b' : 'bcnlat-port';
        break;
      case 'BUOY_GENERIC':
        iconKey = ialaB ? 'boylat-port-b' : 'boylat-port';
        break;
      case 'LIGHT_POINT': {
        // Luz S-52: sector de luz con extremo curvo (arco circular) inclinado ~40°-50° a la derecha.
        // displacement=[0,28] sube 28px — aprox. al topmark de una boya/baliza estándar.
        var raw  = colours[0];
        var lCol = raw === 3 ? '#ee2200' : raw === 4 ? '#00cc00'
                 : raw === 6 ? '#ffcc00' : raw === 12 ? '#cc00aa' : '#ffffff';
        // Sector: fuente (2,20), ángulos ±~8° centrados en 42° de la vertical.
        // Lado izq: 34°→ (2+15*sin34°, 20-15*cos34°) = (10.4, 7.6)
        // Lado der: 50°→ (2+15*sin50°, 20-15*cos50°) = (13.5, 10.4)
        // Extremo curvo: arco de r=15 centrado en (2,20) — clockwise (sweep=1)
        var lSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="22" viewBox="0 0 20 22">' +
          '<path d="M2,20 L10.4,7.6 A15,15 0 0,1 13.5,10.4 Z" fill="' + lCol + '" fill-opacity="0.45" stroke="' + lCol + '" stroke-width="0.5" stroke-opacity="0.75"/>' +
          '<circle cx="2" cy="20" r="4.5" fill="' + lCol + '" fill-opacity="0.15"/>' +
          '<circle cx="2" cy="20" r="2.5" fill="' + lCol + '" fill-opacity="0.92" stroke="rgba(255,255,255,0.85)" stroke-width="0.9"/>' +
          '<circle cx="2" cy="20" r="1.2" fill="white"/>' +
          '</svg>';
        return new ol.style.Style({
          image: new ol.style.Icon({
            src: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(lSvg),
            // anchor en el punto-fuente (2,20) dentro del SVG de 20x22
            anchor: [2/20, 20/22], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
            scale: 1,
            // Desplazar 28px hacia arriba — topmark de boya/baliza estándar ~30px alto
            displacement: [0, 28],
          }),
        });
      }
      case 'LANDMARK': case 'RACON': {
        var nm = feature.get('name') || '';
        // Faro/lighthouse: nombre indica estructura de luz importante
        var isLH = nm.indexOf('Faro') === 0 || nm.indexOf('Light') === 0
                || nm.indexOf('Lighthouse') === 0 || nm.indexOf('Breakwater Light') >= 0;
        if (isLH) {
          iconKey = 'lighthouse';
        } else {
          // Otro hito: diamante marrón ECDIS
          return new ol.style.Style({
            image: new ol.style.RegularShape({
              points: 4, radius: 6, angle: Math.PI / 4,
              fill:   new ol.style.Fill({ color: '#8b6914' }),
              stroke: new ol.style.Stroke({ color: '#fffbe8', width: 1.2 }),
            }),
          });
        }
        break;
      }
      case 'UNKNOWN':
      default: {
        // Círculo con color S-57 del primer COLOUR del objeto
        var fc = colours[0] ? _s57css(colours[0]) : '#607d8b';
        return new ol.style.Style({
          image: new ol.style.Circle({ radius: 4,
            fill:   new ol.style.Fill({ color: fc }),
            stroke: new ol.style.Stroke({ color: '#fff', width: 1.2 }),
          }),
        });
      }
    }

    // Devolver icono SVG (o círculo gris como último fallback)
    var src = iconKey ? _svgSrc[iconKey] : null;
    if (!src) {
      return new ol.style.Style({
        image: new ol.style.Circle({ radius: 5,
          fill:   new ol.style.Fill({ color: '#78909c' }),
          stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }),
        }),
      });
    }
    return new ol.style.Style({
      image: new ol.style.Icon({
        src: src,
        anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
        scale: 1,
      }),
    });
  }

  // ── Paleta de colores por modo (idéntica a AR ECDIS presentation.js) ────────
  // Ayudas a la navegación (boyas/balizas/luces) NUNCA se tocan — colores IALA.
  // Solo se recoloran: DEPARE, LNDARE/LANDMASK, DEPCNT, SOUNDG, COALNE, FAIRWY.
  var _chartMode = 'day';
  // Paleta S-52 idéntica a AR ECDIS (session_summary_20260502.md + ar_ecdis_s52_rendering.md).
  // NUNCA tocar colores de ayudas IALA (boyas/balizas/luces) — solo DEPARE/LNDARE/DEPCNT/SOUNDG.
  // coalne = color del trazo de costa (borde del polígono LNDARE) — oscuro para contraste.
  var _S57_COLORS = {
    night: {
      depare:  ['#232d3d','#1a2330','#141b28','#0f1520','#0a1018'],
      lndare:  '#221e18',  coalne: '#a09878',
      soundgText: '#888880', soundgHalo: 'rgba(8,8,6,0.95)',
      depcnt:  '#3a3d4a',  osmOpacity: 0.18, bgColor: '#000408',
    },
    dusk: {
      depare:  ['#2a4a6c','#213c5c','#1a304e','#152640','#101c30'],
      lndare:  '#302a1e',  coalne: '#908a80',
      soundgText: '#c0c0b0', soundgHalo: 'rgba(14,12,10,0.85)',
      depcnt:  '#506090',  osmOpacity: 0.38, bgColor: '#040c18',
    },
    'day-std': {
      depare:  ['#c8e8f8','#90c8f0','#58a8e0','#3080c8','#1860b0'],
      lndare:  '#f0e8c0',  coalne: '#2a2a2a',
      soundgText: '#0a1e38', soundgHalo: 'rgba(220,236,252,0.70)',
      depcnt:  '#5070a0',  osmOpacity: 0.82, bgColor: '#8ab8d8',
    },
    day: {
      // DAY_BRIGHT — paleta S-52 probada en AR ECDIS (ar_ecdis_s52_rendering.md)
      depare:  ['#c0e8f8','#a0d0f0','#80b8e8','#4078b8','#1a5a90'],
      lndare:  '#dfd5b0',  coalne: '#1a1a1a',
      soundgText: '#000000', soundgHalo: 'rgba(255,255,255,0.75)',
      depcnt:  '#4060a0',  osmOpacity: 0.82, bgColor: '#b8d8f0',
    },
  };

  function _modeColors() {
    return _S57_COLORS[_chartMode] || _S57_COLORS['day'];
  }

  // ── Depth style — recolorea según modo, mantiene colores S-57 ─────────────
  function depthStyle(feature) {
    var layer = feature.get('layer');
    var C = _modeColors();
    if (layer === 'DEPARE') {
      var dmax = feature.get('depth_max');
      if (dmax == null) dmax = 9999;
      var fill;
      if      (dmax <  2) fill = C.depare[0];
      else if (dmax <  5) fill = C.depare[1];
      else if (dmax < 10) fill = C.depare[2];
      else if (dmax < 30) fill = C.depare[3];
      else                fill = C.depare[4];
      return new ol.style.Style({ fill: new ol.style.Fill({ color: fill }) });
    }
    if (layer === 'LANDMASK') {
      return new ol.style.Style({ fill: new ol.style.Fill({ color: C.lndare }) });
    }
    if (layer === 'DEPCNT') {
      var depth = feature.get('depth');
      var isMajor = depth != null && depth % 10 === 0;
      return new ol.style.Style({
        stroke: new ol.style.Stroke({ color: C.depcnt, width: isMajor ? 1.0 : 0.5, lineDash: isMajor ? null : [4,3] }),
      });
    }
    return null;   // SOUNDG → soundgStyle() en soundgLayer dedicado
  }

  // ── Land layer style — diferencia por capa S-57 y modo de presentación ──────
  //
  // PROBLEMA RAÍZ: los polígonos LNDARE del S-57 en puertos (ej. Miami Port) incluyen
  // muelles y diques que flanquean canales de navegación. Rellenarlos sólidamente TAPA
  // el canal de cruceros aunque éste esté correctamente marcado como DEPARE.
  //
  // SOLUCIÓN: en modos día (OSM al 82-90%) NO rellenamos LNDARE — el OSM ya muestra
  // la tierra. Solo ponemos el trazo de costa (COALNE). El canal queda visible porque
  // DEPARE (zIndex 2, azul) no está tapado por LNDARE.
  //
  // En noche/dusk (OSM al 12-38%) SÍ usamos relleno S-52 — sin él la tierra sería
  // indistinguible del océano oscuro.
  function _landStyle(feature) {
    var l = feature.get('layer');
    var C = _modeColors();
    var isDayMode = (_chartMode === 'day' || _chartMode === 'day-std');
    var adv = (_detailLevel === 'advanced');

    // COALNE — trazo de línea de costa
    // CRÍTICO: en S-57 COALNE a veces viene como Polygon (área de celda con la costa como borde).
    // Hacer stroke a un Polygon grande crea líneas diagonales donde el anillo exterior "cierra"
    // desde el último vértice al primero cruzando el agua.
    // Solo hacemos stroke si la geometría es LineString (COALNE real), no Polygon.
    if (l === 'COALNE') {
      if (adv && !_advLayers.coalne) return null;
      var gtype = feature.getGeometry().getType();
      if (gtype === 'Polygon' || gtype === 'MultiPolygon') return null;
      return new ol.style.Style({
        stroke: new ol.style.Stroke({ color: C.coalne, width: isDayMode ? 1.2 : 1.0 }),
      });
    }

    // BUISGL — edificios aislados (modo oscuro)
    if (l === 'BUISGL') {
      if (isDayMode) return null;
      return new ol.style.Style({
        stroke: new ol.style.Stroke({ color: 'rgba(100,80,50,0.55)', width: 0.6 }),
      });
    }

    // BUAARE — zona urbana: borde tenue sin fill
    if (l === 'BUAARE') {
      if (adv && !_advLayers.buaare) return null;
      if (isDayMode) return null;  // en día OSM tiene mejor detalle
      return new ol.style.Style({
        stroke: new ol.style.Stroke({ color: C.coalne, width: 0.7, lineDash: [4, 4] }),
      });
    }

    // LNDARE — polígono de tierra
    // NUNCA hacer stroke: el anillo exterior del polígono LNDARE puede tener un borde de "cierre"
    // que va del último al primer vértice cruzando el agua — diagonal inevitable en S-57.
    // El borde visual costa/agua surge naturalmente del FILL que limita con el color de agua.
    if (l === 'LNDARE') {
      if (isDayMode) return null;  // día: LANDMASK + OSM ya muestran la tierra
      // Modos oscuros: relleno sólido S-52 SIN stroke para evitar diagonales
      if (adv && !_advLayers.lndare) return null;
      return new ol.style.Style({
        fill: new ol.style.Fill({ color: C.lndare }),
      });
    }

    return null;
  }

  // ── Routing helpers — misma depthSource, capas OL distintas ──────────────
  function depAreaStyle(feature) {
    var l = feature.get('layer');
    // LANDMASK: pinta tierra con C.lndare para tapar DEPARE que pueda sobresalir en zonas de tierra.
    // Se renderiza ANTES que DEPARE en el source (ver _parse_depths en chart_manager.py),
    // por lo que DEPARE (canal) se pinta encima → el canal queda visible.
    if (l === 'LANDMASK') {
      if (_detailLevel === 'advanced' && !_advLayers.landmask) return null;
      return depthStyle(feature);
    }
    if (l !== 'DEPARE') return null;
    // En avanzado, respetar flag _advLayers.depare
    if (_detailLevel === 'advanced' && !_advLayers.depare) return null;
    return depthStyle(feature);
  }
  function depLineStyle(feature) {
    var l = feature.get('layer');
    if (l !== 'DEPCNT') return null;   // SOUNDG ya va por soundgLayer dedicado
    // BÁSICO: nunca mostrar veriles — solo áreas DEPARE (manejadas por depAreaStyle)
    if (_detailLevel === 'basic') return null;
    // AVANZADO: respetar flag individual
    if (_detailLevel === 'advanced' && !_advLayers.depcnt) return null;
    return depthStyle(feature);
  }

  // ── Hazard style — SVG S-52 (wrecks/uwtroc/obstrn idénticos a AR ECDIS) ──
  function hazardStyle(feature) {
    var cat = feature.get('category');
    var iconKey = cat === 'wreck' ? 'wrecks' : cat === 'rock' ? 'uwtroc' : 'obstrn';
    var src = _svgSrc[iconKey];
    if (!src) {
      var col = cat === 'wreck' ? '#e53935' : cat === 'rock' ? '#f59e0b' : '#f97316';
      return new ol.style.Style({
        image: new ol.style.Circle({ radius: 6,
          fill:   new ol.style.Fill({ color: col }),
          stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }),
        }),
      });
    }
    return new ol.style.Style({
      image: new ol.style.Icon({
        src: src, scale: 1,
        anchor: [0.5, 0.5], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
      }),
    });
  }

  // ── Zone style ────────────────────────────────────────────────────────────
  function zoneStyle(feature) {
    const cat = feature.get('category');
    // Opacidades elevadas para visibilidad en modo día sobre OSM
    const fills = {
      restricted:   'rgba(220,40,40,0.22)',
      caution:      'rgba(255,150,0,0.22)',
      anchorage:    'rgba(0,100,210,0.22)',
      fairway:      'rgba(0,170,90,0.18)',
      traffic_lane: 'rgba(80,80,240,0.18)',
      precautionary:'rgba(200,120,0,0.18)',
      dumpground:   'rgba(120,80,40,0.18)',
      pipeline_area:'rgba(160,100,180,0.18)',
    };
    const strokes = {
      restricted:   'rgba(200,30,30,0.85)',
      caution:      'rgba(220,130,0,0.85)',
      anchorage:    'rgba(0,80,190,0.85)',
      fairway:      'rgba(0,150,70,0.80)',
      traffic_lane: 'rgba(60,60,210,0.80)',
      precautionary:'rgba(180,100,0,0.80)',
      dumpground:   'rgba(100,60,20,0.80)',
      pipeline_area:'rgba(140,80,160,0.80)',
    };
    const fill   = fills[cat]   || 'rgba(120,120,120,0.14)';
    const stroke = strokes[cat] || 'rgba(100,100,100,0.65)';
    return new ol.style.Style({
      fill:   new ol.style.Fill({ color: fill }),
      stroke: new ol.style.Stroke({ color: stroke, width: 1.2, lineDash: [7,4] }),
    });
  }

  // ── Chart layers — stack S-52 (abajo=mayor área, arriba=menor) ───────────
  //  zIndex 0  bgLayer      ocean bg (map.js)
  //  zIndex 1  osmLayer     OSM opcional (map.js)
  //  zIndex 2  depAreaLayer DEPARE áreas de profundidad (polígonos grandes, bajo tierra)
  //  zIndex 4  landLayer    LNDARE tierra firme
  //  zIndex 5  depLineLayer DEPCNT veriles (líneas) — SOBRE tierra
  //  zIndex 6  zoneLayer    zonas náuticas (semitransparente)
  //  zIndex 7  hazardLayer  naufragios, rocas (puntos)
  //  zIndex 8  encLayer     boyas, balizas, luces (puntos — tope ENC)
  //  zIndex 9  soundgLayer  SOUNDG sondas — capa dedicada, sobre todo lo demás S-57
  //  zIndex 20+ track, rutas, barco propio (map.js)

  // Fuentes
  const depthSource  = new ol.source.Vector();   // DEPARE + DEPCNT + LANDMASK
  const soundgSource = new ol.source.Vector();   // SOUNDG — fuente dedicada (igual que ECDIS)
  const landSource   = new ol.source.Vector();   // LNDARE
  const zoneSource   = new ol.source.Vector();   // zonas
  const encSource    = new ol.source.Vector();   // balizamiento ENC
  const hazardSource = new ol.source.Vector();   // peligros

  // Estilo dedicado para sondas — idéntico a ECDIS: text-allow-overlap + text-ignore-placement
  // Ancla invisible — OL 9 canvas renderer no renderiza texto en Points que solo
  // tienen `text` style sin ningún `image`/`fill`/`stroke`. El Circle radio 1
  // transparente es suficiente para que OL registre el feature como "dibujable"
  // y luego renderice el texto. El usuario no ve el círculo (1px transparente).
  var _soundgAnchor = new ol.style.Circle({
    radius: 1,
    fill: new ol.style.Fill({ color: 'rgba(0,0,0,0)' }),
  });

  function soundgStyle(feature) {
    if (_detailLevel !== 'advanced' || !_advLayers.soundg) return null;
    var d = feature.get('depth');
    if (d == null) return null;
    var C = _modeColors();
    var txt = d < 10 ? d.toFixed(1) : Math.round(d).toString();
    return new ol.style.Style({
      image: _soundgAnchor,   // ancla invisible — necesaria para que OL renderice el texto
      text: new ol.style.Text({
        text: txt,
        font: 'bold 11px Arial, sans-serif',
        textAlign: 'center',
        textBaseline: 'middle',
        fill: new ol.style.Fill({ color: C.soundgText }),
        overflow: true,   // no recortar etiquetas en el borde del tile
      }),
    });
  }

  // Capas
  const depAreaLayer = new ol.layer.Vector({     // polígonos de profundidad (debajo de tierra)
    source: depthSource, zIndex: 2, style: depAreaStyle,
  });
  const landLayer = new ol.layer.Vector({        // tierra — zIndex 4 enmascara veriles bajo tierra
    source: landSource, zIndex: 4,
    style: _landStyle,
  });
  const depLineLayer = new ol.layer.Vector({     // veriles DEPCNT — solo líneas, sin sondas
    source: depthSource, zIndex: 5, style: depLineStyle,
  });
  const soundgLayer = new ol.layer.Vector({      // sondas — capa dedicada como en ECDIS
    source: soundgSource, zIndex: 9,
    style: soundgStyle,
    declutter: false,               // mostrar todas las sondas sin filtrar solapadas
    updateWhileAnimating: true,
    updateWhileInteracting: true,
  });
  const zoneLayer = new ol.layer.Vector({        // zonas náuticas — encima de veriles
    source: zoneSource, zIndex: 6, style: zoneStyle,
  });
  const hazardLayer = new ol.layer.Vector({      // naufragios / rocas
    source: hazardSource, zIndex: 7, style: hazardStyle, maxResolution: 600, declutter: true,
  });
  const encLayer = new ol.layer.Vector({         // boyas / balizas / luces
    source: encSource, zIndex: 8, style: encStyle,
  });

  // Attach layers — idempotent: skip if already added
  var _layersAttached = false;
  var _moveendTimer = null;
  function _attachLayers() {
    if (_layersAttached) return;
    var olMap = GPSMap.getOLMap();
    if (!olMap) return;
    // Orden de addLayer no importa — OL usa zIndex para pintar.
    [depAreaLayer, landLayer, zoneLayer, depLineLayer, hazardLayer, encLayer, soundgLayer].forEach(function(l) {
      olMap.addLayer(l);
    });
    // Cargar celdas nuevas al mover/hacer zoom (debounced 800ms)
    olMap.on('moveend', function() {
      clearTimeout(_moveendTimer);
      _moveendTimer = setTimeout(function() {
        if (window.py) loadAll();
      }, 800);
    });

    // ── Hover: tooltip sobre ayudas ENC ─────────────────────────────────────
    // Solo capas de puntos (encLayer, hazardLayer).
    // Las zonas (polígonos grandes) NO participan en hover — bloquearían los puntos.
    var _ptLayers = [encLayer, hazardLayer];
    olMap.on('pointermove', function(evt) {
      if (evt.dragging) { _hideTooltip(); return; }
      var feat = olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {
        if (_ptLayers.indexOf(l) >= 0) return f;
      }, { hitTolerance: 14 });

      if (feat) {
        // evt.pixel es relativo al viewport del mapa — correcto para posicionar tooltip
        var px = evt.pixel[0];
        var py = evt.pixel[1];
        _showTooltip(feat, px, py);
        if (!window.GPSMap || GPSMap.getDrawMode() === 'none') {
          olMap.getTargetElement().style.cursor = 'pointer';
        }
      } else {
        _hideTooltip();
        if (!window.GPSMap || GPSMap.getDrawMode() === 'none') {
          olMap.getTargetElement().style.cursor = '';
        }
      }
    });

    // ── Click: mostrar info en panel derecho ─────────────────────────────────
    olMap.on('click', function(evt) {
      if (window.GPSMap && GPSMap.getDrawMode() !== 'none') return;
      var feat = olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {
        if (_ptLayers.indexOf(l) >= 0) return f;
      }, { hitTolerance: 16 });
      if (feat) _showInfoPanel(feat);
    });

    _layersAttached = true;
  }

  // ── GeoJSON loaders ───────────────────────────────────────────────────────
  // _loadGeoJSON: limpia la fuente y carga desde cero (usado en reloadAll)
  function _loadGeoJSON(source, geojson) {
    source.clear();
    return _addGeoJSON(source, geojson);
  }

  // _addGeoJSON: añade features SIN limpiar (carga progresiva de celdas)
  function _addGeoJSON(source, geojson) {
    if (!geojson || !geojson.features || !geojson.features.length) return 0;
    try {
      var fmt = new ol.format.GeoJSON();
      var features = fmt.readFeatures(geojson, {
        dataProjection:    'EPSG:4326',
        featureProjection: 'EPSG:3857',
      });
      source.addFeatures(features);
      return features.length;
    } catch(e) {
      console.warn('[ChartLayer] _addGeoJSON error:', e);
      return 0;
    }
  }

  // _addDepthsGeoJSON: split depths — SOUNDG → soundgSource, el resto → depthSource
  // Necesario porque OL no renderiza texto-solo Points mezclados con Line/Polygon en misma fuente.
  function _addDepthsGeoJSON(fc) {
    if (!fc || !fc.features) return [0, 0];
    var soundg = [], others = [];
    fc.features.forEach(function(f) {
      if (f.properties && f.properties.layer === 'SOUNDG') soundg.push(f);
      else others.push(f);
    });
    console.log('[ChartLayer] depths split → depthSource:', others.length,
                '| soundgSource:', soundg.length,
                '| soundg flag:', _advLayers.soundg);
    var nD = _addGeoJSON(depthSource,  { type: 'FeatureCollection', features: others });
    var nS = _addGeoJSON(soundgSource, { type: 'FeatureCollection', features: soundg });
    return [nD || 0, nS || 0];
  }

  // ── Icon cache — usar toDataURL para máxima compat. con WebEngine ─────────
  var _iconCache = {};
  function _cachedIcon(key, drawFn) {
    if (_iconCache[key]) return _iconCache[key];
    try {
      var cv = drawFn();
      _iconCache[key] = cv ? cv.toDataURL('image/png') : null;
    } catch(e) {
      _iconCache[key] = null;
    }
    return _iconCache[key];
  }

  // Wrapper que usa toDataURL en lugar de img: canvas
  var _origCi = _ci;
  function _ciUrl(key, fn) {
    if (_iconCache[key] !== undefined) return _iconCache[key];
    try {
      var cv = fn();
      _iconCache[key] = cv ? cv.toDataURL('image/png') : null;
    } catch(e) {
      _iconCache[key] = null;
    }
    return _iconCache[key];
  }

  // ── Lectura per-celda via bridge Python ──────────────────────────────────
  // Igual que ECDIS: un slot por celda/tipo → cada respuesta cabe en QWebChannel.
  // Se evita fetch() HTTP que puede fallar según el esquema de carga (file://).
  async function _fetchCellFile(cellId, dataType, addRegion, region) {
    try {
      var raw = await _py('get_cell_data', cellId, dataType);
      var fc = JSON.parse(raw);
      var feats = fc.features || [];
      if (addRegion) {
        feats.forEach(function(f) {
          if (!f.properties) f.properties = {};
          f.properties.cell_region = region;
        });
      }
      return feats;
    } catch(e) {
      console.warn('[ChartLayer] get_cell_data failed:', cellId, dataType, e.message || e);
      return [];
    }
  }

  // Agrega features de todas las celdas para un tipo de dato dado
  async function _loadAllCells(cells, dataType, addRegion) {
    // Carga secuencial para no saturar el QWebChannel con muchos slots en vuelo
    var all = [];
    for (var i = 0; i < cells.length; i++) {
      var c = cells[i];
      var feats = await _fetchCellFile(c.id, dataType, addRegion, c.region || 'B');
      all = all.concat(feats);
    }
    return { type: 'FeatureCollection', features: all };
  }

  // ── Filtro de celdas por viewport ─────────────────────────────────────────
  // Devuelve las celdas cuyo bbox se solapa con el extent del mapa actual.
  // extent = [minLon, minLat, maxLon, maxLat]
  function _cellsInView(cells, extent) {
    var minLon = extent[0], minLat = extent[1], maxLon = extent[2], maxLat = extent[3];
    return cells.filter(function(c) {
      if (!c.bbox || c.bbox.length < 4) return false;
      var w = c.bbox[0], s = c.bbox[1], e = c.bbox[2], n = c.bbox[3];
      // Filtro AABB: ¿hay solapamiento?
      return w <= maxLon && e >= minLon && s <= maxLat && n >= minLat;
    });
  }

  // Convierte el extent del mapa OL (EPSG:3857) a [minLon,minLat,maxLon,maxLat]
  function _mapExtentLonLat() {
    try {
      var olMap = GPSMap.getOLMap();
      if (!olMap) return null;
      var sz = olMap.getSize();
      if (!sz || !sz[0] || !sz[1]) return null;   // mapa sin renderizar aún
      var ext3857 = olMap.getView().calculateExtent(sz);
      if (!ext3857 || isNaN(ext3857[0])) return null;
      var sw = ol.proj.toLonLat([ext3857[0], ext3857[1]]);
      var ne = ol.proj.toLonLat([ext3857[2], ext3857[3]]);
      if (isNaN(sw[0]) || isNaN(ne[0])) return null;
      return [sw[0], sw[1], ne[0], ne[1]];
    } catch(e) {
      console.warn('[ChartLayer] _mapExtentLonLat error:', e.message || e);
      return null;
    }
  }

  // ── Nivel de detalle ─────────────────────────────────────────────────────
  // 'basic'    → aids + land + DEPARE fills          (sin líneas de veril)
  // 'medium'   → + veriles (DEPCNT) + hazards        (4 tipos)
  // 'advanced' → capas seleccionables via modal
  var _detailLevel = 'basic';

  // Flags por capa para nivel 'advanced' (controlados por modal de capas)
  var _advLayers = {
    // ── Profundidades ──────────────────────────────────────────────────────
    depare:   true,   // áreas de profundidad (fills azules)
    depcnt:   true,   // veriles / isobatas (líneas)
    soundg:   false,  // sondas (texto de profundidad)
    hazards:  true,   // naufragios, rocas, obstrucciones
    zones:    false,  // zonas restringidas, fondeo, tráfico
    // ── Tierra / costa ────────────────────────────────────────────────────
    coalne:   true,   // COALNE — trazo de línea de costa
    lndare:   false,  // LNDARE — bordes del polígono tierra (genera líneas diagonales)
    landmask: true,   // LANDMASK — relleno beige de tierra S-57
    buaare:   false,  // BUAARE — límites de zonas urbanas
    // ── Mapa base ─────────────────────────────────────────────────────────
    osm:      true,   // OSM tiles (mapa base)
  };

  function _dataTypes() {
    if (_detailLevel === 'advanced') {
      // Construir lista según flags individuales
      var types = ['features', 'land'];
      if (_advLayers.depare || _advLayers.depcnt || _advLayers.soundg) types.push('depths');
      if (_advLayers.hazards) types.push('hazards');
      if (_advLayers.zones)   types.push('zones');
      return types;
    }
    if (_detailLevel === 'medium')
      return ['features', 'land', 'depths', 'hazards'];
    // basic: aids + tierra + DEPARE fills (sin líneas — depLineStyle las filtra)
    return ['features', 'land', 'depths'];
  }

  function setLayerVisibility(opts) {
    Object.keys(opts).forEach(function(k) {
      if (k in _advLayers) _advLayers[k] = !!opts[k];
    });
    // Refrescar estilos inmediatamente
    depthSource.changed();
    soundgSource.changed();
    landSource.changed();
    hazardLayer.setVisible(_advLayers.hazards);
    zoneLayer.setVisible(_advLayers.zones);
    // OSM — toggle visibilidad del tile layer
    var olMap = GPSMap.getOLMap();
    if (olMap) {
      olMap.getLayers().forEach(function(lyr) {
        if (lyr && lyr.getSource && lyr.getSource() instanceof ol.source.OSM) {
          lyr.setVisible(_advLayers.osm);
        }
      });
    }
    // Recargar solo si se activó un tipo que no estaba cargado
    _loading = false;
    _loadedCellIds.clear();
    loadAll();
  }

  function getAdvLayers() { return Object.assign({}, _advLayers); }

  // ── Public API ────────────────────────────────────────────────────────────
  var _loadedCellIds = new Set();  // evita recargar celdas ya cargadas
  var _loading = false;            // guard anti-reentrada: QWebChannel es secuencial

  async function loadAll() {
    _attachLayers();
    if (!window.py) { console.warn('[ChartLayer] bridge not ready'); return; }
    if (_loading) { console.log('[ChartLayer] carga en curso, ignorando llamada duplicada'); return; }
    _loading = true;

    // Obtener lista de celdas del bridge (JSON pequeño — sin problema de tamaño)
    var allCells;
    try {
      allCells = JSON.parse(await _py('get_chart_cells'));
    } catch(e) {
      console.warn('[ChartLayer] get_chart_cells failed:', e.message || e);
      _loading = false; return;
    }
    if (!allCells || !allCells.length) {
      console.warn('[ChartLayer] no cells installed');
      _loading = false; return;
    }

    var cellsWithBbox = allCells.filter(function(c){ return c.bbox; });

    // Filtro de viewport — siempre usar extent si está disponible
    var extent = _mapExtentLonLat();
    var cells;
    if (extent) {
      cells = _cellsInView(cellsWithBbox, extent);
      console.log('[ChartLayer] nivel:', _detailLevel,
        '| extent:', extent.map(function(v){return v.toFixed(2);}).join(','),
        '→', cells.length, '/', cellsWithBbox.length, 'celdas en view');
    } else {
      // Fallback: sin extent válido (primer frame), cargar todas
      cells = cellsWithBbox;
      console.log('[ChartLayer] nivel:', _detailLevel,
        '| sin extent — todas las celdas con bbox:', cells.length);
    }

    // Filtrar celdas ya cargadas con el nivel actual
    // (al cambiar nivel se hace reloadAll que limpia _loadedCellIds)
    cells = cells.filter(function(c){ return !_loadedCellIds.has(c.id); });
    if (!cells.length) {
      console.log('[ChartLayer] sin celdas nuevas que cargar');
      _loading = false; return;
    }

    var types = _dataTypes();
    console.log('[ChartLayer] cargando', cells.length, 'celdas × [' + types.join(', ') + ']:',
      cells.slice(0,6).map(function(c){ return c.id; }).join(', '),
      cells.length > 6 ? '...' : '');

    // Carga secuencial por celda/tipo — cada mensaje QWebChannel < ~700 KB
    try {
      var nFeat=0, nLand=0, nDep=0, nSoundg=0, nHaz=0, nZone=0;

      if (types.indexOf('features') >= 0) {
        var feat = await _loadAllCells(cells, 'features', true);
        nFeat = _addGeoJSON(encSource, feat) || 0;
      }
      if (types.indexOf('land') >= 0) {
        var land = await _loadAllCells(cells, 'land', false);
        nLand = _addGeoJSON(landSource, land) || 0;
      }
      if (types.indexOf('hazards') >= 0) {
        var haz = await _loadAllCells(cells, 'hazards', false);
        nHaz = _addGeoJSON(hazardSource, haz) || 0;
      }
      if (types.indexOf('depths') >= 0) {
        var dep = await _loadAllCells(cells, 'depths', false);
        var depCounts = _addDepthsGeoJSON(dep);   // split: SOUNDG → soundgSource
        nDep = depCounts[0]; nSoundg = depCounts[1];
      }
      if (types.indexOf('zones') >= 0) {
        var zone = await _loadAllCells(cells, 'zones', false);
        nZone = _addGeoJSON(zoneSource, zone) || 0;
      }

      cells.forEach(function(c){ _loadedCellIds.add(c.id); });
      console.log('[ChartLayer] LISTO — aids:', nFeat, 'land:', nLand,
        'depths:', nDep, 'soundg:', nSoundg, 'hazards:', nHaz, 'zones:', nZone);
    } finally {
      _loading = false;
    }
  }

  async function reloadAll() {
    // Limpiar fuentes y set de celdas cargadas, luego recargar el viewport
    [landSource, zoneSource, depthSource, soundgSource, encSource, hazardSource].forEach(function(s){ s.clear(); });
    _loadedCellIds.clear();
    _loading = false;   // resetear guard para que loadAll pueda arrancar
    return loadAll();
  }

  function setDetailLevel(lvl) {
    if (lvl !== 'basic' && lvl !== 'medium' && lvl !== 'advanced') return;
    _detailLevel = lvl;
    ['basic', 'medium', 'advanced'].forEach(function(l) {
      var btn = document.getElementById('enc-lvl-' + l);
      if (btn) btn.classList.toggle('active', l === lvl);
    });
    reloadAll();
  }

  // ── setChartMode — recolorea capas S-57 por modo (sin tocar ayudas IALA) ──
  // Mismo principio que AR ECDIS presentation.js:
  //   · DEPARE/LNDARE/DEPCNT/SOUNDG → fill color cambia por modo
  //   · BOYLAT/BOYCAR/LIGHTS/etc.   → colores IALA, NUNCA cambian
  //   · OSM tiles                   → opacidad reducida en modos oscuros
  function setChartMode(mode) {
    var validModes = ['night', 'dusk', 'day-std', 'day'];
    if (validModes.indexOf(mode) < 0) return;
    _chartMode = mode;
    // Recolorar capas de área/profundidad — los SVG de ayudas no se tocan
    depthSource.changed();
    soundgSource.changed();   // sondas en capa dedicada también se recoloran
    // landLayer usa _landStyle (función) que lee _chartMode dinámicamente — solo changed()
    landSource.changed();
    // Ajustar opacidad OSM — oscuro en modos noche/dusk, normal en día
    var C = _modeColors();
    var olMap = GPSMap.getOLMap();
    if (olMap) {
      olMap.getLayers().forEach(function(lyr) {
        if (lyr && lyr.getSource && lyr.getSource() instanceof ol.source.OSM) {
          lyr.setOpacity(C.osmOpacity);
        }
      });
    }
    console.log('[ChartLayer] modo presentación:', mode, '| OSM opacity:', C.osmOpacity);
  }

  // ── Hover tooltip & click info panel ─────────────────────────────────────
  // Tooltip: div flotante sobre el mapa, aparece solo en hover.
  // Panel:   sección en panel derecho, aparece al hacer click en una ayuda.

  var _ttEl = null;   // tooltip DOM element

  function _ensureTT() {
    if (_ttEl) return _ttEl;
    var wrap = document.getElementById('map-wrap');
    if (!wrap) return null;
    _ttEl = document.createElement('div');
    _ttEl.className = 'enc-tooltip';
    _ttEl.style.display = 'none';
    wrap.appendChild(_ttEl);
    return _ttEl;
  }

  // Texto HTML escapado
  function _esc(s) {
    return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }

  // Formato grados-minutos "11°01.234'N"
  function _fmtDeg(deg, pPos, pNeg) {
    var a = Math.abs(deg), d = Math.floor(a), m = (a - d) * 60;
    return d + '°' + m.toFixed(3) + '’' + (deg < 0 ? pNeg : pPos);
  }

  // Extraer info estructurada de un feature OL
  function _featInfo(f) {
    var aid    = f.get('aid_type') || '';
    var layer  = (f.get('layer')   || '').toUpperCase();
    var name   = f.get('name')     || '';
    var light  = f.get('light_desc') || '';
    var cat    = f.get('category') || '';
    var region = f.get('cell_region') || 'B';
    var colours= f.get('colours')  || [];
    var depth  = f.get('depth');
    var dmax   = f.get('depth_max');

    var TYPE_LABELS = {
      'LATERAL_PORT':      'Lateral Port',      'LATERAL_STBD':      'Lateral Starboard',
      'LATERAL_PREF_PORT': 'Pref. Ch. Port',    'LATERAL_PREF_STBD': 'Pref. Ch. Stbd',
      'LATERAL_UNKNOWN':   'Lateral Unknown',   'CARDINAL_N':        'Cardinal N',
      'CARDINAL_S':        'Cardinal S',        'CARDINAL_E':        'Cardinal E',
      'CARDINAL_W':        'Cardinal W',        'CARDINAL_UNKNOWN':  'Cardinal',
      'ISOLATED_DANGER':   'Isolated Danger',   'SAFE_WATER':        'Safe Water',
      'SPECIAL':           'Special Mark',      'LIGHT_POINT':       'Light',
      'LANDMARK':          'Landmark',          'RACON':             'Racon',
      'BEACON_GENERIC':    'Beacon',            'BUOY_GENERIC':      'Buoy',
      'UNKNOWN':           'Unknown',
    };
    var CAT = { wreck:'Wreck', rock:'Underwater Rock', obstruction:'Obstruction' };
    var CNAMES = {1:'White',2:'Black',3:'Red',4:'Green',5:'Blue',6:'Yellow',
                  7:'Grey',8:'Brown',9:'Amber',10:'Violet',11:'Orange',12:'Magenta'};

    var typeLabel = TYPE_LABELS[aid] || CAT[cat] || layer || 'Feature';
    var objClass  = layer.startsWith('BCN') ? 'Beacon' : (layer.indexOf('BOY') >= 0 ? 'Buoy' : '');
    if (objClass && typeLabel.indexOf(objClass) < 0) typeLabel = typeLabel + ' ' + objClass;
    var regionStr = region === 'A' ? 'IALA-A' : 'IALA-B';
    var colorStr  = colours.map(function(c) { return CNAMES[c] || ''; }).filter(Boolean).join('/');

    // Coordenadas del objeto
    var coords = null;
    try {
      var geom = f.getGeometry();
      if (geom) {
        var pt = geom.getType() === 'Point'
          ? geom.getCoordinates()
          : ol.extent.getCenter(geom.getExtent());
        if (pt) coords = ol.proj.toLonLat(pt);
      }
    } catch(e) {}

    return { aid, layer, name, light, cat, region, regionStr, colours, colorStr,
             depth, dmax, coords, typeLabel };
  }

  // Posicionar tooltip cerca del cursor (evita salirse del mapa)
  function _posTT(px, py) {
    if (!_ttEl) return;
    var wrap = document.getElementById('map-wrap');
    var W = wrap ? wrap.offsetWidth  : 800;
    var H = wrap ? wrap.offsetHeight : 600;
    var ew = _ttEl.offsetWidth  || 180;
    var eh = _ttEl.offsetHeight || 52;
    var left = px + 16, top = py - Math.round(eh / 2);
    if (left + ew > W - 8) left = px - ew - 12;
    if (top < 4) top = 4;
    if (top + eh > H - 4) top = H - eh - 4;
    _ttEl.style.left = left + 'px';
    _ttEl.style.top  = top  + 'px';
  }

  // Capas que NO deben mostrar tooltip (demasiado ruido)
  var _noTooltipLayers = { DEPARE:1, LANDMASK:1, LNDARE:1, DEPCNT:1, SOUNDG:1 };

  function _showTooltip(f, px, py) {
    var el = _ensureTT(); if (!el) return;
    var info = _featInfo(f);
    if (_noTooltipLayers[info.layer]) { _hideTooltip(); return; }

    var html = '<div class="enc-tt-type">' + _esc(info.typeLabel) + '</div>';
    if (info.name)  html += '<div class="enc-tt-name">' + _esc(info.name) + '</div>';
    if (info.light) html += '<div class="enc-tt-light">💡 ' + _esc(info.light) + '</div>';
    else if (info.colorStr) html += '<div class="enc-tt-cat">' + _esc(info.colorStr) + '</div>';
    el.innerHTML = html;
    el.style.display = 'block';
    // Posicionar después de renderizar (para leer offsetWidth real)
    setTimeout(function() { _posTT(px, py); }, 0);
  }

  function _hideTooltip() {
    if (_ttEl) _ttEl.style.display = 'none';
  }

  // ── Info panel (panel derecho) ────────────────────────────────────────────
  function _showInfoPanel(f) {
    var el = document.getElementById('rp-feat-info');
    if (!el) return;
    var info = _featInfo(f);

    // Color del badge según tipo de ayuda
    var bc = '#607d8b';
    if (info.aid.indexOf('PORT')  >= 0) bc = '#009900';
    if (info.aid.indexOf('STBD')  >= 0) bc = '#cc1111';
    if (info.aid.indexOf('CARDINAL') >= 0) bc = '#f0c000';
    if (info.aid === 'ISOLATED_DANGER') bc = '#cc1111';
    if (info.aid === 'SAFE_WATER')      bc = '#ee2222';
    if (info.aid === 'LIGHT_POINT')     bc = '#cc00cc';
    if (info.aid === 'LANDMARK')        bc = '#8b6914';
    if (info.cat === 'wreck')   bc = '#e53935';
    if (info.cat === 'rock')    bc = '#f59e0b';

    function row(lbl, val, cls) {
      if (!val && val !== 0) return '';
      return '<div class="fi-row' + (cls ? ' ' + cls : '') + '">' +
        '<span class="fi-lbl">' + lbl + '</span>' +
        '<span class="fi-val">' + _esc(String(val)) + '</span></div>';
    }

    var rows = '';
    if (info.name)     rows += row('Name',    info.name);
    if (info.light)    rows += row('Light',   info.light,   'fi-light');
    if (info.colorStr) rows += row('Colors',  info.colorStr);
    if (info.aid && (info.aid.indexOf('LATERAL') >= 0 || info.aid.indexOf('CARDINAL') >= 0))
                       rows += row('Region',  info.regionStr);
    if (info.cat)      rows += row('Cat.',    info.cat);
    if (info.dmax != null)  rows += row('Depth max', info.dmax.toFixed(1) + ' m');
    if (info.depth != null) rows += row('Depth',     info.depth.toFixed(1) + ' m');
    if (info.coords) {
      rows += row('Lat', _fmtDeg(info.coords[1], 'N', 'S'));
      rows += row('Lon', _fmtDeg(info.coords[0], 'E', 'W'));
    }

    el.innerHTML =
      '<div class="fi-header">' +
        '<span class="fi-badge" style="color:' + bc + '">&#9632;</span>' +
        '<span class="fi-type">' + _esc(info.typeLabel) + '</span>' +
        '<button class="fi-close" onclick="ChartLayer.hideFeatureInfo()">×</button>' +
      '</div>' +
      (rows ? '<div class="fi-rows">' + rows + '</div>'
            : '<div class="fi-empty">No hay datos adicionales</div>');
    el.style.display = 'block';
  }

  function hideFeatureInfo() {
    var el = document.getElementById('rp-feat-info');
    if (el) el.style.display = 'none';
  }

  return { loadAll, reloadAll, setDetailLevel, setLayerVisibility, getAdvLayers,
           setChartMode, hideFeatureInfo,
           encSource, depthSource, hazardSource, zoneSource, landSource };
})();
