'use strict';
/* S-57 ENC chart layer for GPS Navigator.
Attaches to GPSMap.getOLMap() and exposes window.ChartLayer. */
const ChartLayer = (function () {
// ── Canvas symbol cache ──────────────────────────────────────────────────
const _cache = {};
function _ci(key, fn) { if (!_cache[key]) _cache[key] = fn(); return _cache[key]; }
// ── S-57 colour code → CSS colour ────────────────────────────────────────
const S57_CSS = {
1:'#ffffff', 2:'#111111', 3:'#e53935', 4:'#2e7d32',
5:'#1565c0', 6:'#f9a825', 7:'#78909c', 8:'#6d4c41',
9:'#ff8f00', 10:'#7b1fa2', 11:'#ef6c00', 12:'#e91e63',
};
function _s57css(c) { return S57_CSS[c] || '#78909c'; }
// ── Canvas helpers ───────────────────────────────────────────────────────
function _mkC(sz) {
const c = document.createElement('canvas'); c.width = sz; c.height = sz; return c;
}
function _wl(ctx, cx, y, hw) {
ctx.strokeStyle = '#1a6bb5'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cx - hw, y); ctx.lineTo(cx + hw, y); ctx.stroke();
}
function _st(ctx, cx, y1, y2) {
ctx.strokeStyle = '#222'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cx, y1); ctx.lineTo(cx, y2); ctx.stroke();
}
function _tmConeUp(ctx, cx, y0, w, h, fill) {
ctx.beginPath(); ctx.moveTo(cx, y0); ctx.lineTo(cx+w/2, y0+h); ctx.lineTo(cx-w/2, y0+h); ctx.closePath();
ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke();
}
function _tmConeDown(ctx, cx, y0, w, h, fill) {
ctx.beginPath(); ctx.moveTo(cx-w/2, y0); ctx.lineTo(cx+w/2, y0); ctx.lineTo(cx, y0+h); ctx.closePath();
ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke();
}
function _tmSphere(ctx, cx, cy, r, fill) {
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke();
}
function _tmX(ctx, cx, cy, r, col) {
ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx-r, cy-r); ctx.lineTo(cx+r, cy+r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx+r, cy-r); ctx.lineTo(cx-r, cy+r); ctx.stroke();
}
// ── 3D helpers ───────────────────────────────────────────────────────────
function _h2r(hex) {
hex = (hex || '').replace('#','');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
if (hex.length < 6) return [120, 144, 156]; // fallback gris neutro — evita NaN
const r = parseInt(hex.slice(0,2),16);
const g = parseInt(hex.slice(2,4),16);
const b = parseInt(hex.slice(4,6),16);
return [isNaN(r)?120:r, isNaN(g)?144:g, isNaN(b)?156:b];
}
function _lighten(hex, f=0.45) {
const [r,g,b] = _h2r(hex);
return `rgb(${Math.min(255,Math.round(r+(255-r)*f))},${Math.min(255,Math.round(g+(255-g)*f))},${Math.min(255,Math.round(b+(255-b)*f))})`;
}
function _darken(hex, f=0.45) {
const [r,g,b] = _h2r(hex);
return `rgb(${Math.round(r*(1-f))},${Math.round(g*(1-f))},${Math.round(b*(1-f))})`;
}
function _cylGrad(ctx, x, w, yMid, col) {
const g = ctx.createLinearGradient(x, yMid, x+w, yMid);
g.addColorStop(0, _lighten(col,0.58)); g.addColorStop(0.22, _lighten(col,0.18));
g.addColorStop(0.55, col); g.addColorStop(0.82, _darken(col,0.32));
g.addColorStop(1, _darken(col,0.55)); return g;
}
function _coneGrad(ctx, x, w, yMid, col) {
const g = ctx.createLinearGradient(x, yMid, x+w, yMid);
g.addColorStop(0, _lighten(col,0.65)); g.addColorStop(0.20, _lighten(col,0.20));
g.addColorStop(0.55, col); g.addColorStop(1, _darken(col,0.60)); return g;
}
function _fillBands3D(ctx, pathFn, bx, by, bw, bh, css, gradFn) {
ctx.save(); pathFn(ctx); ctx.clip();
if (!css.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx,by,bw,bh); }
else {
const bandH = bh / css.length;
css.forEach((col,i) => {
const y0 = by + i*bandH;
ctx.fillStyle = gradFn(ctx, bx, bw, y0+bandH/2, col);
ctx.fillRect(bx, y0, bw, bandH+1);
});
}
ctx.restore();
}
function _ialaLateralColours(catlam, region) {
const ialaB = (region||'B') === 'B';
const G=4, R=3;
const port = ialaB ? G : R, stbd = ialaB ? R : G;
if (catlam===1) return [port]; if (catlam===2) return [stbd];
if (catlam===3) return [port,stbd,port]; if (catlam===4) return [stbd,port,stbd];
return [];
}
// ── S-52 light flare ─────────────────────────────────────────────────────
function _drawLightFlare(ctx, cx, y, col) {
const MAG='#ff00aa', len=8;
ctx.save(); ctx.translate(cx, y);
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(len*0.95,-len*0.30); ctx.lineTo(len*0.30,-len*0.95); ctx.closePath();
ctx.fillStyle = MAG; ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=0.5; ctx.stroke();
ctx.beginPath(); ctx.arc(0,0,1.5,0,Math.PI*2); ctx.fillStyle=MAG; ctx.fill();
if (col && col!==MAG) {
ctx.beginPath(); ctx.arc(len*0.62,-len*0.62,1.8,0,Math.PI*2);
ctx.fillStyle=col; ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=0.4; ctx.stroke();
}
ctx.restore();
}
// ── 3D buoy canvas ───────────────────────────────────────────────────────
function _encBuoyCanvas(opts) {
const sz = opts.sz || 52;
const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz/2;
let cc = (opts.colours||[]).slice();
if (!cc.length && opts.catlam) cc = _ialaLateralColours(opts.catlam, opts.region);
const css = cc.map(_s57css);
const c1 = css[0] || '#78909c';
const wlY=sz*0.92, bBot=sz*0.88, bTop=sz*0.52, bW=sz*0.52, bH=bBot-bTop;
const eRy=bH*0.095;
const _tmRoom=sz*0.52*0.40;
const stTop = opts.hasLight ? Math.max(sz*0.20,_tmRoom+sz*0.03) : Math.max(sz*0.24,_tmRoom+sz*0.03);
const glowCol = c1 ? _lighten(c1,0.55) : 'rgba(255,255,255,0.7)';
const outln = (lw=0.9) => { ctx.shadowBlur=0; ctx.strokeStyle='rgba(0,0,0,0.75)'; ctx.lineWidth=lw; ctx.stroke(); };
const drawCylinder = (x, topY, w, h, colList) => {
const ery=h*0.095;
_fillBands3D(ctx, cc=>{ cc.beginPath(); cc.rect(x,topY,w,h); }, x, topY, w, h, colList, _cylGrad);
ctx.shadowBlur=5; ctx.shadowColor=glowCol;
ctx.strokeStyle='rgba(255,255,255,0.45)'; ctx.lineWidth=1.0; ctx.strokeRect(x,topY,w,h);
ctx.shadowBlur=0; ctx.strokeStyle='rgba(0,0,0,0.55)'; ctx.lineWidth=0.6; ctx.strokeRect(x,topY,w,h);
ctx.beginPath(); ctx.ellipse(x+w/2,topY+h,w/2,ery,0,0,Math.PI*2);
ctx.fillStyle=_darken(colList[colList.length-1]||c1,0.5); ctx.fill(); outln(0.5);
const tg=ctx.createRadialGradient(x+w*0.3,topY,1,x+w/2,topY,w/2);
tg.addColorStop(0,'rgba(255,255,255,0.80)'); tg.addColorStop(0.5,_lighten(colList[0]||c1,0.25)); tg.addColorStop(1,colList[0]||c1);
ctx.beginPath(); ctx.ellipse(x+w/2,topY,w/2,ery,0,0,Math.PI*2);
ctx.fillStyle=tg; ctx.fill(); outln(0.5);
};
const drawCone = (cx2, apexY, baseY, w, colList) => {
const ery=(baseY-apexY)*0.10, bx=cx2-w/2;
_fillBands3D(ctx, cc=>{ cc.beginPath(); cc.moveTo(cx2,apexY); cc.lineTo(cx2+w/2,baseY); cc.lineTo(cx2-w/2,baseY); cc.closePath(); },
bx,apexY,w,baseY-apexY,colList,_coneGrad);
ctx.beginPath(); ctx.moveTo(cx2,apexY); ctx.lineTo(cx2+w/2,baseY); ctx.lineTo(cx2-w/2,baseY); ctx.closePath();
ctx.shadowBlur=5; ctx.shadowColor=glowCol; ctx.strokeStyle='rgba(255,255,255,0.40)'; ctx.lineWidth=1.0; ctx.stroke(); ctx.shadowBlur=0;
ctx.beginPath(); ctx.moveTo(cx2,apexY); ctx.lineTo(cx2+w/2,baseY); ctx.lineTo(cx2-w/2,baseY); ctx.closePath(); outln(0.9);
ctx.beginPath(); ctx.ellipse(cx2,baseY,w/2,ery,0,0,Math.PI*2);
ctx.fillStyle=_darken(colList[colList.length-1]||c1,0.40); ctx.fill(); outln(0.5);
};
const drawSphere = (cx2, cy2, r, colList) => {
ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); ctx.fillStyle=colList[0]||'#78909c'; ctx.fill();
ctx.save(); ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); ctx.clip();
const rg=ctx.createRadialGradient(cx2-r*0.38,cy2-r*0.38,r*0.04,cx2,cy2,r);
rg.addColorStop(0,'rgba(255,255,255,0.72)'); rg.addColorStop(0.30,'rgba(255,255,255,0.18)');
rg.addColorStop(0.65,'rgba(0,0,0,0.00)'); rg.addColorStop(1,'rgba(0,0,0,0.42)');
ctx.fillStyle=rg; ctx.fillRect(cx2-r,cy2-r,r*2,r*2); ctx.restore();
ctx.beginPath(); ctx.arc(cx2,cy2,r,0,Math.PI*2); outln(0.8);
};
const drawTopmarkCan = (tx,ty,w,col) => { const h=w; drawCylinder(tx-w/2,ty-h,w,h,[col]); };
const drawTopmarkCone = (tx,ty,w,col) => { const h=w*0.9; drawCone(tx,ty-h,ty,w,[col]); };
const drawTopmarkConeSmall = (tx,ty,w,col) => { const h=w*0.7; drawCone(tx,ty-h,ty,w,[col]); };
const bs=opts.boyshp, cl=opts.catlam;
const isCan=bs===2||(!bs&&(cl===1||cl===3));
const isCone=bs===1||(!bs&&(cl===2||cl===4));
const isSph=bs===3, isSpar=bs===5, isBar=bs===6, isSuper=bs===7;
if (isCan) {
drawCylinder(cx-bW/2,bTop,bW,bH,css.length?css:[c1]);
_st(ctx,cx,bTop-eRy,stTop); drawTopmarkCan(cx,stTop,bW*0.42,c1);
} else if (isCone) {
drawCone(cx,bTop,bBot,bW,css.length?css:[c1]);
_st(ctx,cx,bTop,stTop); drawTopmarkCone(cx,stTop,bW*0.44,c1);
} else if (isSph) {
const r=bH*0.44, bcy=bTop+r; drawSphere(cx,bcy,r,css.length?css:[c1]);
_st(ctx,cx,bcy-r,stTop); _tmSphere(ctx,cx,stTop-sz*0.09,sz*0.09,c1);
} else if (isSpar) {
const sw=bW*0.24, sparTop=bTop-bH*0.40;
drawCylinder(cx-sw/2,sparTop,sw,bH*1.40,css.length?css:[c1]);
if (cl===1||cl===3) drawTopmarkCan(cx,sparTop-2,sw*1.4,c1);
else if (cl===2||cl===4) drawTopmarkCone(cx,sparTop-2,sw*1.4,c1);
else drawTopmarkConeSmall(cx,sparTop-2,sw*1.4,c1);
} else {
// PILLAR (default)
const pw=bW*0.48;
drawCylinder(cx-pw/2,bTop+bH*0.4,pw,bH*0.6,css.length?css:[c1]);
drawCylinder(cx-pw*0.35,bTop,pw*0.7,bH*0.4,[_darken(c1,0.08)]);
_st(ctx,cx,bTop,stTop);
if (cl===1||cl===3) drawTopmarkCan(cx,stTop,bW*0.42,c1);
else if (cl===2||cl===4) drawTopmarkCone(cx,stTop,bW*0.44,c1);
else drawTopmarkConeSmall(cx,stTop,bW*0.50,c1);
}
_wl(ctx,cx,wlY,bW*0.68);
if (opts.hasLight) _drawLightFlare(ctx,cx,stTop+sz*0.02,css[0]||'#ffffff');
return c;
}
// ── Cardinal buoy canvas ─────────────────────────────────────────────────
function _encCardinalCanvas(quadrant, sz=40) {
const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
const BLK='#111111', YEL='#f9a825';
const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.20;
const bW=sz*0.44, bH=bBot-bTop;
const bands = {N:[BLK,YEL],S:[YEL,BLK],E:[BLK,YEL,BLK],W:[YEL,BLK,YEL]}[quadrant]||[BLK,YEL];
const bandH=bH/bands.length;
bands.forEach((col,i)=>{ ctx.fillStyle=col; ctx.fillRect(cx-bW/2,bTop+i*bandH,bW,bandH); });
ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
_st(ctx,cx,bTop,sTop);
const cw=sz*0.36, ch=sz*0.14, t1=sTop-ch*0.2, t2=t1-ch-sz*0.01;
if (quadrant==='N') { _tmConeUp(ctx,cx,t2,cw,ch,BLK); _tmConeUp(ctx,cx,t1,cw,ch,BLK); }
else if (quadrant==='S') { _tmConeDown(ctx,cx,t2,cw,ch,BLK); _tmConeDown(ctx,cx,t1,cw,ch,BLK); }
else if (quadrant==='E') { _tmConeUp(ctx,cx,t2,cw,ch,BLK); _tmConeDown(ctx,cx,t1,cw,ch,BLK); }
else { _tmConeDown(ctx,cx,t2,cw,ch,BLK); _tmConeUp(ctx,cx,t1,cw,ch,BLK); }
_wl(ctx,cx,wlY,bW*0.65);
return c;
}
// ── Isolated danger ──────────────────────────────────────────────────────
function _encIsdCanvas(sz=40) {
const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28;
const bW=sz*0.44, bH=bBot-bTop;
ctx.fillStyle='#111'; ctx.fillRect(cx-bW/2,bTop,bW,bH);
ctx.fillStyle='#e53935'; ctx.fillRect(cx-bW/2,bTop+bH*0.35,bW,bH*0.30);
ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
_st(ctx,cx,bTop,sTop);
const sr=sz*0.07; _tmSphere(ctx,cx-sr*1.2,sTop-sr,sr,'#111'); _tmSphere(ctx,cx+sr*1.2,sTop-sr,sr,'#111');
_wl(ctx,cx,wlY,bW*0.65); return c;
}
// ── Safe water ───────────────────────────────────────────────────────────
function _encSawCanvas(sz=40) {
const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28;
const bW=sz*0.44, bH=bBot-bTop;
ctx.save(); ctx.beginPath(); ctx.rect(cx-bW/2,bTop,bW,bH); ctx.clip();
for (let i=0;i<4;i++) { ctx.fillStyle=i%2===0?'#e53935':'#ffffff'; ctx.fillRect(cx-bW/2+i*bW/4,bTop,bW/4,bH); }
ctx.restore(); ctx.strokeStyle='#444'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
_st(ctx,cx,bTop,sTop); _tmSphere(ctx,cx,sTop-sz*0.07,sz*0.07,'#e53935');
_wl(ctx,cx,wlY,bW*0.65); return c;
}
// ── Special buoy ─────────────────────────────────────────────────────────
function _encSppCanvas(sz=40) {
const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
const wlY=sz*0.88, bBot=sz*0.84, bTop=sz*0.50, sTop=sz*0.28;
const bW=sz*0.44, bH=bBot-bTop;
ctx.fillStyle='#f9a825'; ctx.fillRect(cx-bW/2,bTop,bW,bH);
ctx.strokeStyle='#555'; ctx.lineWidth=0.8; ctx.strokeRect(cx-bW/2,bTop,bW,bH);
_st(ctx,cx,bTop,sTop); _tmX(ctx,cx,sTop-sz*0.09,sz*0.10,'#f9a825');
_wl(ctx,cx,wlY,bW*0.65); return c;
}
// ── Beacon ───────────────────────────────────────────────────────────────
function _encBeaconCanvas(colours, catlam, region, sz=36) {
const c=_mkC(sz); const ctx=c.getContext('2d'); const cx=sz/2;
let cc=(colours||[]).slice();
if (!cc.length&&catlam) cc=_ialaLateralColours(catlam, region);
const css=cc.map(_s57css); const c1=css[0]||'#78909c';
const ialaB=(region||'B')==='B';
const groundY=sz*0.92, poleTopY=sz*0.04, poleW=sz*0.055;
const bodyW=sz*0.28, bodyTopY=sz*0.48, bodyBotY=groundY;
const tmW=sz*0.36, tmBotY=sz*0.27, tmTopY=sz*0.05;
// Ground shadow
ctx.beginPath(); ctx.ellipse(cx,groundY,sz*0.12,sz*0.020,0,0,Math.PI*2);
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fill();
// Pole
const poleGrd=ctx.createLinearGradient(cx-poleW,0,cx+poleW,0);
poleGrd.addColorStop(0,'#999'); poleGrd.addColorStop(0.4,'#ddd'); poleGrd.addColorStop(1,'#777');
ctx.fillStyle=poleGrd; ctx.fillRect(cx-poleW/2,poleTopY,poleW,groundY-poleTopY);
// Body
const bodyGrd=ctx.createLinearGradient(cx-bodyW/2,0,cx+bodyW/2,0);
bodyGrd.addColorStop(0,_lighten(c1,0.35)); bodyGrd.addColorStop(0.45,c1); bodyGrd.addColorStop(1,_darken(c1,0.28));
ctx.fillStyle=bodyGrd; ctx.fillRect(cx-bodyW/2,bodyTopY,bodyW,bodyBotY-bodyTopY);
ctx.strokeStyle='rgba(0,0,0,0.65)'; ctx.lineWidth=0.9;
ctx.strokeRect(cx-bodyW/2,bodyTopY,bodyW,bodyBotY-bodyTopY);
// Topmark: PORT = square, STBD = triangle
const isPort=catlam===1||catlam===3||(ialaB&&cc[0]===4)||(!ialaB&&cc[0]===3);
if (isPort) {
ctx.fillStyle=c1; ctx.fillRect(cx-tmW/2,tmTopY,tmW,tmBotY-tmTopY);
ctx.strokeStyle='#111'; ctx.lineWidth=0.8; ctx.strokeRect(cx-tmW/2,tmTopY,tmW,tmBotY-tmTopY);
} else {
ctx.beginPath(); ctx.moveTo(cx,tmTopY); ctx.lineTo(cx+tmW/2,tmBotY); ctx.lineTo(cx-tmW/2,tmBotY); ctx.closePath();
ctx.fillStyle=c1; ctx.fill(); ctx.strokeStyle='#111'; ctx.lineWidth=0.8; ctx.stroke();
}
return c;
}
// ── Lighthouse / light symbol (S-52 style) ───────────────────────────────
function _ialaLight(colourCode) {
const W=26, H=46;
const c=document.createElement('canvas'); c.width=W; c.height=H;
const ctx=c.getContext('2d'); const cx=W/2;
let col='#ffffff';
if (colourCode===3) col='#dd1111';
else if (colourCode===4) col='#00aa00';
else if (colourCode===6) col='#ffcc00';
const bW=14, bH=17, bTop=H*0.50, bBot=bTop+bH;
ctx.beginPath(); ctx.rect(cx-bW/2,bTop,bW,bH);
ctx.fillStyle=col; ctx.fill(); ctx.strokeStyle='#111'; ctx.lineWidth=1.2; ctx.stroke();
// 5-point star
const starX=cx, starY=bTop+bH/2, Ro=4.5, Ri=1.85;
ctx.beginPath();
for (let i=0;i<10;i++) {
const ang=(i*Math.PI/5)-Math.PI/2, rr=(i%2===0)?Ro:Ri;
const px=starX+Math.cos(ang)*rr, py=starY+Math.sin(ang)*rr;
i===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
}
ctx.closePath(); ctx.fillStyle=(col==='#ffffff')?'#222':'#fff'; ctx.fill();
// Magenta light flash
const PURP='#8800cc', tearAng=20*Math.PI/180, tearLen=18;
ctx.save(); ctx.translate(cx+bW/2-1,bTop+1); ctx.rotate(tearAng);
ctx.beginPath(); ctx.moveTo(0,0);
ctx.bezierCurveTo(-3.8,-5,-3.8,-11,0,-tearLen);
ctx.bezierCurveTo(3.8,-11,3.8,-5,0,0);
ctx.closePath(); ctx.fillStyle=PURP; ctx.globalAlpha=0.88; ctx.fill(); ctx.globalAlpha=1.0;
ctx.strokeStyle='rgba(90,0,140,0.5)'; ctx.lineWidth=0.5; ctx.stroke(); ctx.restore();
// Anchor dot
ctx.beginPath(); ctx.arc(cx,H-2,2.2,0,Math.PI*2); ctx.fillStyle='#fff'; ctx.fill();
ctx.beginPath(); ctx.arc(cx,H-2,1.0,0,Math.PI*2); ctx.fillStyle='#111'; ctx.fill();
return c;
}
// ── Light point (infers buoy shape from colour+region) ───────────────────
function _encLightCanvas(colours, region, sz=32) {
const raw=colours[0];
const ialaB=(region||'B')==='B';
// White/magenta lights → simple lighthouse symbol
if (!raw || raw===1 || raw===12) return _ialaLight(raw||1);
// Coloured light → infer lateral buoy
const isPort = (ialaB&&raw===4)||(!ialaB&&raw===3);
const isStbd = (ialaB&&raw===3)||(!ialaB&&raw===4);
if (isPort||isStbd) {
const catlam = isPort?1:2;
return _encBuoyCanvas({ colours, boyshp: isPort?2:1, catlam, region, sz, hasLight:true });
}
if (raw===6) return _encSppCanvas(sz); // yellow = special
return _ialaLight(raw);
}
// ── SVG icon library — idénticos a AR ECDIS (anchor=bottom) ─────────────
var _svgSrc = (function () {
// Punto de posición S-52: pequeño, sutil, marca la posición exacta de la ayuda
var D = function (cx, cy) {
return '';
};
var icons = {
// ── Lateral IALA-B ──────────────────────────────────────────────────
'boylat-port-b': '',
'boylat-stbd-b': '',
// ── Lateral IALA-A ──────────────────────────────────────────────────
'boylat-port': '',
'boylat-stbd': '',
// ── Balizas IALA-B ──────────────────────────────────────────────────
'bcnlat-port-b': '',
'bcnlat-stbd-b': '',
// ── Balizas IALA-A ──────────────────────────────────────────────────
'bcnlat-port': '',
'bcnlat-stbd': '',
// ── Cardinales ──────────────────────────────────────────────────────
'boycar-n': '',
'boycar-s': '',
'boycar-e': '',
'boycar-w': '',
// ── Especiales ──────────────────────────────────────────────────────
'boyisd': '',
'boysaw': '',
'boyspp': '',
// ── Peligros S-52 ────────────────────────────────────────────────────
// Wreck (WRECKS) — silueta de barco hundido, magenta S-52, contorno blanco para visibilidad
'wrecks': '',
// Roca sumergida (UWTROC) — asterisco de 8 puntas, naranja vivo S-52
'uwtroc': '',
// Obstrucción (OBSTRN) — rombo con X, amarillo-naranja S-52
'obstrn': '',
// ── Faro ────────────────────────────────────────────────────────────
'lighthouse': '',
};
var out = {};
Object.keys(icons).forEach(function (k) {
out[k] = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(icons[k]);
});
return out;
})();
// ── ENC feature style — SIN etiquetas estáticas (solo en hover/click) ─────
// Las etiquetas se muestran únicamente en tooltip al hacer hover.
function encStyle(feature) {
var aidType = feature.get('aid_type') || '';
var layer = (feature.get('layer') || '').toUpperCase();
var region = feature.get('cell_region') || 'B';
var isBcn = layer.startsWith('BCN');
var ialaB = region !== 'A';
var colours = feature.get('colours') || [];
var iconKey = null;
switch (aidType) {
case 'LATERAL_PORT': case 'LATERAL_PREF_STBD': case 'LATERAL_UNKNOWN':
iconKey = isBcn ? (ialaB ? 'bcnlat-port-b' : 'bcnlat-port')
: (ialaB ? 'boylat-port-b' : 'boylat-port');
break;
case 'LATERAL_STBD': case 'LATERAL_PREF_PORT':
iconKey = isBcn ? (ialaB ? 'bcnlat-stbd-b' : 'bcnlat-stbd')
: (ialaB ? 'boylat-stbd-b' : 'boylat-stbd');
break;
case 'CARDINAL_N': iconKey = 'boycar-n'; break;
case 'CARDINAL_S': iconKey = 'boycar-s'; break;
case 'CARDINAL_E': iconKey = 'boycar-e'; break;
case 'CARDINAL_W': iconKey = 'boycar-w'; break;
case 'CARDINAL_UNKNOWN': iconKey = 'boycar-n'; break;
case 'ISOLATED_DANGER': iconKey = 'boyisd'; break;
case 'SAFE_WATER': iconKey = 'boysaw'; break;
case 'SPECIAL': iconKey = 'boyspp'; break;
// Genéricos — fallback a símbolo por defecto del tipo
case 'BEACON_GENERIC':
iconKey = ialaB ? 'bcnlat-port-b' : 'bcnlat-port';
break;
case 'BUOY_GENERIC':
iconKey = ialaB ? 'boylat-port-b' : 'boylat-port';
break;
case 'LIGHT_POINT': {
// Luz S-52: sector de luz con extremo curvo (arco circular) inclinado ~40°-50° a la derecha.
// displacement=[0,28] sube 28px — aprox. al topmark de una boya/baliza estándar.
var raw = colours[0];
var lCol = raw === 3 ? '#ee2200' : raw === 4 ? '#00cc00'
: raw === 6 ? '#ffcc00' : raw === 12 ? '#cc00aa' : '#ffffff';
// Sector: fuente (2,20), ángulos ±~8° centrados en 42° de la vertical.
// Lado izq: 34°→ (2+15*sin34°, 20-15*cos34°) = (10.4, 7.6)
// Lado der: 50°→ (2+15*sin50°, 20-15*cos50°) = (13.5, 10.4)
// Extremo curvo: arco de r=15 centrado en (2,20) — clockwise (sweep=1)
var lSvg = '';
return new ol.style.Style({
image: new ol.style.Icon({
src: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(lSvg),
// anchor en el punto-fuente (2,20) dentro del SVG de 20x22
anchor: [2/20, 20/22], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
scale: 1,
// Desplazar 28px hacia arriba — topmark de boya/baliza estándar ~30px alto
displacement: [0, 28],
}),
});
}
case 'LANDMARK': case 'RACON': {
var nm = feature.get('name') || '';
// Faro/lighthouse: nombre indica estructura de luz importante
var isLH = nm.indexOf('Faro') === 0 || nm.indexOf('Light') === 0
|| nm.indexOf('Lighthouse') === 0 || nm.indexOf('Breakwater Light') >= 0;
if (isLH) {
iconKey = 'lighthouse';
} else {
// Otro hito: diamante marrón ECDIS
return new ol.style.Style({
image: new ol.style.RegularShape({
points: 4, radius: 6, angle: Math.PI / 4,
fill: new ol.style.Fill({ color: '#8b6914' }),
stroke: new ol.style.Stroke({ color: '#fffbe8', width: 1.2 }),
}),
});
}
break;
}
case 'UNKNOWN':
default: {
// Círculo con color S-57 del primer COLOUR del objeto
var fc = colours[0] ? _s57css(colours[0]) : '#607d8b';
return new ol.style.Style({
image: new ol.style.Circle({ radius: 4,
fill: new ol.style.Fill({ color: fc }),
stroke: new ol.style.Stroke({ color: '#fff', width: 1.2 }),
}),
});
}
}
// Devolver icono SVG (o círculo gris como último fallback)
var src = iconKey ? _svgSrc[iconKey] : null;
if (!src) {
return new ol.style.Style({
image: new ol.style.Circle({ radius: 5,
fill: new ol.style.Fill({ color: '#78909c' }),
stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }),
}),
});
}
return new ol.style.Style({
image: new ol.style.Icon({
src: src,
anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
scale: 1,
}),
});
}
// ── Paleta de colores por modo (idéntica a AR ECDIS presentation.js) ────────
// Ayudas a la navegación (boyas/balizas/luces) NUNCA se tocan — colores IALA.
// Solo se recoloran: DEPARE, LNDARE/LANDMASK, DEPCNT, SOUNDG, COALNE, FAIRWY.
var _chartMode = 'day';
// Paleta S-52 idéntica a AR ECDIS (session_summary_20260502.md + ar_ecdis_s52_rendering.md).
// NUNCA tocar colores de ayudas IALA (boyas/balizas/luces) — solo DEPARE/LNDARE/DEPCNT/SOUNDG.
// coalne = color del trazo de costa (borde del polígono LNDARE) — oscuro para contraste.
var _S57_COLORS = {
night: {
depare: ['#232d3d','#1a2330','#141b28','#0f1520','#0a1018'],
lndare: '#221e18', coalne: '#a09878',
soundgText: '#888880', soundgHalo: 'rgba(8,8,6,0.95)',
depcnt: '#3a3d4a', osmOpacity: 0.18, bgColor: '#000408',
},
dusk: {
depare: ['#2a4a6c','#213c5c','#1a304e','#152640','#101c30'],
lndare: '#302a1e', coalne: '#908a80',
soundgText: '#c0c0b0', soundgHalo: 'rgba(14,12,10,0.85)',
depcnt: '#506090', osmOpacity: 0.38, bgColor: '#040c18',
},
'day-std': {
depare: ['#c8e8f8','#90c8f0','#58a8e0','#3080c8','#1860b0'],
lndare: '#f0e8c0', coalne: '#2a2a2a',
soundgText: '#0a1e38', soundgHalo: 'rgba(220,236,252,0.70)',
depcnt: '#5070a0', osmOpacity: 0.82, bgColor: '#8ab8d8',
},
day: {
// DAY_BRIGHT — paleta S-52 probada en AR ECDIS (ar_ecdis_s52_rendering.md)
depare: ['#c0e8f8','#a0d0f0','#80b8e8','#4078b8','#1a5a90'],
lndare: '#dfd5b0', coalne: '#1a1a1a',
soundgText: '#000000', soundgHalo: 'rgba(255,255,255,0.75)',
depcnt: '#4060a0', osmOpacity: 0.82, bgColor: '#b8d8f0',
},
};
function _modeColors() {
return _S57_COLORS[_chartMode] || _S57_COLORS['day'];
}
// ── Depth style — recolorea según modo, mantiene colores S-57 ─────────────
function depthStyle(feature) {
var layer = feature.get('layer');
var C = _modeColors();
if (layer === 'DEPARE') {
var dmax = feature.get('depth_max');
if (dmax == null) dmax = 9999;
var fill;
if (dmax < 2) fill = C.depare[0];
else if (dmax < 5) fill = C.depare[1];
else if (dmax < 10) fill = C.depare[2];
else if (dmax < 30) fill = C.depare[3];
else fill = C.depare[4];
return new ol.style.Style({ fill: new ol.style.Fill({ color: fill }) });
}
if (layer === 'LANDMASK') {
return new ol.style.Style({ fill: new ol.style.Fill({ color: C.lndare }) });
}
if (layer === 'DEPCNT') {
var depth = feature.get('depth');
var isMajor = depth != null && depth % 10 === 0;
return new ol.style.Style({
stroke: new ol.style.Stroke({ color: C.depcnt, width: isMajor ? 1.0 : 0.5, lineDash: isMajor ? null : [4,3] }),
});
}
return null; // SOUNDG → soundgStyle() en soundgLayer dedicado
}
// ── Land layer style — diferencia por capa S-57 y modo de presentación ──────
//
// PROBLEMA RAÍZ: los polígonos LNDARE del S-57 en puertos (ej. Miami Port) incluyen
// muelles y diques que flanquean canales de navegación. Rellenarlos sólidamente TAPA
// el canal de cruceros aunque éste esté correctamente marcado como DEPARE.
//
// SOLUCIÓN: en modos día (OSM al 82-90%) NO rellenamos LNDARE — el OSM ya muestra
// la tierra. Solo ponemos el trazo de costa (COALNE). El canal queda visible porque
// DEPARE (zIndex 2, azul) no está tapado por LNDARE.
//
// En noche/dusk (OSM al 12-38%) SÍ usamos relleno S-52 — sin él la tierra sería
// indistinguible del océano oscuro.
function _landStyle(feature) {
var l = feature.get('layer');
var C = _modeColors();
var isDayMode = (_chartMode === 'day' || _chartMode === 'day-std');
var adv = (_detailLevel === 'advanced');
// COALNE — trazo de línea de costa
// CRÍTICO: en S-57 COALNE a veces viene como Polygon (área de celda con la costa como borde).
// Hacer stroke a un Polygon grande crea líneas diagonales donde el anillo exterior "cierra"
// desde el último vértice al primero cruzando el agua.
// Solo hacemos stroke si la geometría es LineString (COALNE real), no Polygon.
if (l === 'COALNE') {
if (adv && !_advLayers.coalne) return null;
var gtype = feature.getGeometry().getType();
if (gtype === 'Polygon' || gtype === 'MultiPolygon') return null;
return new ol.style.Style({
stroke: new ol.style.Stroke({ color: C.coalne, width: isDayMode ? 1.2 : 1.0 }),
});
}
// BUISGL — edificios aislados (modo oscuro)
if (l === 'BUISGL') {
if (isDayMode) return null;
return new ol.style.Style({
stroke: new ol.style.Stroke({ color: 'rgba(100,80,50,0.55)', width: 0.6 }),
});
}
// BUAARE — zona urbana: borde tenue sin fill
if (l === 'BUAARE') {
if (adv && !_advLayers.buaare) return null;
if (isDayMode) return null; // en día OSM tiene mejor detalle
return new ol.style.Style({
stroke: new ol.style.Stroke({ color: C.coalne, width: 0.7, lineDash: [4, 4] }),
});
}
// LNDARE — polígono de tierra
// NUNCA hacer stroke: el anillo exterior del polígono LNDARE puede tener un borde de "cierre"
// que va del último al primer vértice cruzando el agua — diagonal inevitable en S-57.
// El borde visual costa/agua surge naturalmente del FILL que limita con el color de agua.
if (l === 'LNDARE') {
if (isDayMode) return null; // día: LANDMASK + OSM ya muestran la tierra
// Modos oscuros: relleno sólido S-52 SIN stroke para evitar diagonales
if (adv && !_advLayers.lndare) return null;
return new ol.style.Style({
fill: new ol.style.Fill({ color: C.lndare }),
});
}
return null;
}
// ── Routing helpers — misma depthSource, capas OL distintas ──────────────
function depAreaStyle(feature) {
var l = feature.get('layer');
// LANDMASK: pinta tierra con C.lndare para tapar DEPARE que pueda sobresalir en zonas de tierra.
// Se renderiza ANTES que DEPARE en el source (ver _parse_depths en chart_manager.py),
// por lo que DEPARE (canal) se pinta encima → el canal queda visible.
if (l === 'LANDMASK') {
if (_detailLevel === 'advanced' && !_advLayers.landmask) return null;
return depthStyle(feature);
}
if (l !== 'DEPARE') return null;
// En avanzado, respetar flag _advLayers.depare
if (_detailLevel === 'advanced' && !_advLayers.depare) return null;
return depthStyle(feature);
}
function depLineStyle(feature) {
var l = feature.get('layer');
if (l !== 'DEPCNT') return null; // SOUNDG ya va por soundgLayer dedicado
// BÁSICO: nunca mostrar veriles — solo áreas DEPARE (manejadas por depAreaStyle)
if (_detailLevel === 'basic') return null;
// AVANZADO: respetar flag individual
if (_detailLevel === 'advanced' && !_advLayers.depcnt) return null;
return depthStyle(feature);
}
// ── Hazard style — SVG S-52 (wrecks/uwtroc/obstrn idénticos a AR ECDIS) ──
function hazardStyle(feature) {
var cat = feature.get('category');
var iconKey = cat === 'wreck' ? 'wrecks' : cat === 'rock' ? 'uwtroc' : 'obstrn';
var src = _svgSrc[iconKey];
if (!src) {
var col = cat === 'wreck' ? '#e53935' : cat === 'rock' ? '#f59e0b' : '#f97316';
return new ol.style.Style({
image: new ol.style.Circle({ radius: 6,
fill: new ol.style.Fill({ color: col }),
stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }),
}),
});
}
return new ol.style.Style({
image: new ol.style.Icon({
src: src, scale: 1,
anchor: [0.5, 0.5], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
}),
});
}
// ── Zone style ────────────────────────────────────────────────────────────
function zoneStyle(feature) {
const cat = feature.get('category');
// Opacidades elevadas para visibilidad en modo día sobre OSM
const fills = {
restricted: 'rgba(220,40,40,0.22)',
caution: 'rgba(255,150,0,0.22)',
anchorage: 'rgba(0,100,210,0.22)',
fairway: 'rgba(0,170,90,0.18)',
traffic_lane: 'rgba(80,80,240,0.18)',
precautionary:'rgba(200,120,0,0.18)',
dumpground: 'rgba(120,80,40,0.18)',
pipeline_area:'rgba(160,100,180,0.18)',
};
const strokes = {
restricted: 'rgba(200,30,30,0.85)',
caution: 'rgba(220,130,0,0.85)',
anchorage: 'rgba(0,80,190,0.85)',
fairway: 'rgba(0,150,70,0.80)',
traffic_lane: 'rgba(60,60,210,0.80)',
precautionary:'rgba(180,100,0,0.80)',
dumpground: 'rgba(100,60,20,0.80)',
pipeline_area:'rgba(140,80,160,0.80)',
};
const fill = fills[cat] || 'rgba(120,120,120,0.14)';
const stroke = strokes[cat] || 'rgba(100,100,100,0.65)';
return new ol.style.Style({
fill: new ol.style.Fill({ color: fill }),
stroke: new ol.style.Stroke({ color: stroke, width: 1.2, lineDash: [7,4] }),
});
}
// ── Chart layers — stack S-52 (abajo=mayor área, arriba=menor) ───────────
// zIndex 0 bgLayer ocean bg (map.js)
// zIndex 1 osmLayer OSM opcional (map.js)
// zIndex 2 depAreaLayer DEPARE áreas de profundidad (polígonos grandes, bajo tierra)
// zIndex 4 landLayer LNDARE tierra firme
// zIndex 5 depLineLayer DEPCNT veriles (líneas) — SOBRE tierra
// zIndex 6 zoneLayer zonas náuticas (semitransparente)
// zIndex 7 hazardLayer naufragios, rocas (puntos)
// zIndex 8 encLayer boyas, balizas, luces (puntos — tope ENC)
// zIndex 9 soundgLayer SOUNDG sondas — capa dedicada, sobre todo lo demás S-57
// zIndex 20+ track, rutas, barco propio (map.js)
// Fuentes
const depthSource = new ol.source.Vector(); // DEPARE + DEPCNT + LANDMASK
const soundgSource = new ol.source.Vector(); // SOUNDG — fuente dedicada (igual que ECDIS)
const landSource = new ol.source.Vector(); // LNDARE
const zoneSource = new ol.source.Vector(); // zonas
const encSource = new ol.source.Vector(); // balizamiento ENC
const hazardSource = new ol.source.Vector(); // peligros
// Estilo dedicado para sondas — idéntico a ECDIS: text-allow-overlap + text-ignore-placement
// Ancla invisible — OL 9 canvas renderer no renderiza texto en Points que solo
// tienen `text` style sin ningún `image`/`fill`/`stroke`. El Circle radio 1
// transparente es suficiente para que OL registre el feature como "dibujable"
// y luego renderice el texto. El usuario no ve el círculo (1px transparente).
var _soundgAnchor = new ol.style.Circle({
radius: 1,
fill: new ol.style.Fill({ color: 'rgba(0,0,0,0)' }),
});
function soundgStyle(feature) {
if (_detailLevel !== 'advanced' || !_advLayers.soundg) return null;
var d = feature.get('depth');
if (d == null) return null;
var C = _modeColors();
var txt = d < 10 ? d.toFixed(1) : Math.round(d).toString();
return new ol.style.Style({
image: _soundgAnchor, // ancla invisible — necesaria para que OL renderice el texto
text: new ol.style.Text({
text: txt,
font: 'bold 11px Arial, sans-serif',
textAlign: 'center',
textBaseline: 'middle',
fill: new ol.style.Fill({ color: C.soundgText }),
overflow: true, // no recortar etiquetas en el borde del tile
}),
});
}
// Capas
const depAreaLayer = new ol.layer.Vector({ // polígonos de profundidad (debajo de tierra)
source: depthSource, zIndex: 2, style: depAreaStyle,
});
const landLayer = new ol.layer.Vector({ // tierra — zIndex 4 enmascara veriles bajo tierra
source: landSource, zIndex: 4,
style: _landStyle,
});
const depLineLayer = new ol.layer.Vector({ // veriles DEPCNT — solo líneas, sin sondas
source: depthSource, zIndex: 5, style: depLineStyle,
});
const soundgLayer = new ol.layer.Vector({ // sondas — capa dedicada como en ECDIS
source: soundgSource, zIndex: 9,
style: soundgStyle,
declutter: false, // mostrar todas las sondas sin filtrar solapadas
updateWhileAnimating: true,
updateWhileInteracting: true,
});
const zoneLayer = new ol.layer.Vector({ // zonas náuticas — encima de veriles
source: zoneSource, zIndex: 6, style: zoneStyle,
});
const hazardLayer = new ol.layer.Vector({ // naufragios / rocas
source: hazardSource, zIndex: 7, style: hazardStyle, maxResolution: 600, declutter: true,
});
const encLayer = new ol.layer.Vector({ // boyas / balizas / luces
source: encSource, zIndex: 8, style: encStyle,
});
// Attach layers — idempotent: skip if already added
var _layersAttached = false;
var _moveendTimer = null;
function _attachLayers() {
if (_layersAttached) return;
var olMap = GPSMap.getOLMap();
if (!olMap) return;
// Orden de addLayer no importa — OL usa zIndex para pintar.
[depAreaLayer, landLayer, zoneLayer, depLineLayer, hazardLayer, encLayer, soundgLayer].forEach(function(l) {
olMap.addLayer(l);
});
// Cargar celdas nuevas al mover/hacer zoom (debounced 800ms)
olMap.on('moveend', function() {
clearTimeout(_moveendTimer);
_moveendTimer = setTimeout(function() {
if (window.py) loadAll();
}, 800);
});
// ── Hover: tooltip sobre ayudas ENC ─────────────────────────────────────
// Solo capas de puntos (encLayer, hazardLayer).
// Las zonas (polígonos grandes) NO participan en hover — bloquearían los puntos.
var _ptLayers = [encLayer, hazardLayer];
olMap.on('pointermove', function(evt) {
if (evt.dragging) { _hideTooltip(); return; }
var feat = olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {
if (_ptLayers.indexOf(l) >= 0) return f;
}, { hitTolerance: 14 });
if (feat) {
// evt.pixel es relativo al viewport del mapa — correcto para posicionar tooltip
var px = evt.pixel[0];
var py = evt.pixel[1];
_showTooltip(feat, px, py);
if (!window.GPSMap || GPSMap.getDrawMode() === 'none') {
olMap.getTargetElement().style.cursor = 'pointer';
}
} else {
_hideTooltip();
if (!window.GPSMap || GPSMap.getDrawMode() === 'none') {
olMap.getTargetElement().style.cursor = '';
}
}
});
// ── Click: mostrar info en panel derecho ─────────────────────────────────
olMap.on('click', function(evt) {
if (window.GPSMap && GPSMap.getDrawMode() !== 'none') return;
var feat = olMap.forEachFeatureAtPixel(evt.pixel, function(f, l) {
if (_ptLayers.indexOf(l) >= 0) return f;
}, { hitTolerance: 16 });
if (feat) _showInfoPanel(feat);
});
_layersAttached = true;
}
// ── GeoJSON loaders ───────────────────────────────────────────────────────
// _loadGeoJSON: limpia la fuente y carga desde cero (usado en reloadAll)
function _loadGeoJSON(source, geojson) {
source.clear();
return _addGeoJSON(source, geojson);
}
// _addGeoJSON: añade features SIN limpiar (carga progresiva de celdas)
function _addGeoJSON(source, geojson) {
if (!geojson || !geojson.features || !geojson.features.length) return 0;
try {
var fmt = new ol.format.GeoJSON();
var features = fmt.readFeatures(geojson, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
source.addFeatures(features);
return features.length;
} catch(e) {
console.warn('[ChartLayer] _addGeoJSON error:', e);
return 0;
}
}
// _addDepthsGeoJSON: split depths — SOUNDG → soundgSource, el resto → depthSource
// Necesario porque OL no renderiza texto-solo Points mezclados con Line/Polygon en misma fuente.
function _addDepthsGeoJSON(fc) {
if (!fc || !fc.features) return [0, 0];
var soundg = [], others = [];
fc.features.forEach(function(f) {
if (f.properties && f.properties.layer === 'SOUNDG') soundg.push(f);
else others.push(f);
});
console.log('[ChartLayer] depths split → depthSource:', others.length,
'| soundgSource:', soundg.length,
'| soundg flag:', _advLayers.soundg);
var nD = _addGeoJSON(depthSource, { type: 'FeatureCollection', features: others });
var nS = _addGeoJSON(soundgSource, { type: 'FeatureCollection', features: soundg });
return [nD || 0, nS || 0];
}
// ── Icon cache — usar toDataURL para máxima compat. con WebEngine ─────────
var _iconCache = {};
function _cachedIcon(key, drawFn) {
if (_iconCache[key]) return _iconCache[key];
try {
var cv = drawFn();
_iconCache[key] = cv ? cv.toDataURL('image/png') : null;
} catch(e) {
_iconCache[key] = null;
}
return _iconCache[key];
}
// Wrapper que usa toDataURL en lugar de img: canvas
var _origCi = _ci;
function _ciUrl(key, fn) {
if (_iconCache[key] !== undefined) return _iconCache[key];
try {
var cv = fn();
_iconCache[key] = cv ? cv.toDataURL('image/png') : null;
} catch(e) {
_iconCache[key] = null;
}
return _iconCache[key];
}
// ── Lectura per-celda via bridge Python ──────────────────────────────────
// Igual que ECDIS: un slot por celda/tipo → cada respuesta cabe en QWebChannel.
// Se evita fetch() HTTP que puede fallar según el esquema de carga (file://).
async function _fetchCellFile(cellId, dataType, addRegion, region) {
try {
var raw = await _py('get_cell_data', cellId, dataType);
var fc = JSON.parse(raw);
var feats = fc.features || [];
if (addRegion) {
feats.forEach(function(f) {
if (!f.properties) f.properties = {};
f.properties.cell_region = region;
});
}
return feats;
} catch(e) {
console.warn('[ChartLayer] get_cell_data failed:', cellId, dataType, e.message || e);
return [];
}
}
// Agrega features de todas las celdas para un tipo de dato dado
async function _loadAllCells(cells, dataType, addRegion) {
// Carga secuencial para no saturar el QWebChannel con muchos slots en vuelo
var all = [];
for (var i = 0; i < cells.length; i++) {
var c = cells[i];
var feats = await _fetchCellFile(c.id, dataType, addRegion, c.region || 'B');
all = all.concat(feats);
}
return { type: 'FeatureCollection', features: all };
}
// ── Filtro de celdas por viewport ─────────────────────────────────────────
// Devuelve las celdas cuyo bbox se solapa con el extent del mapa actual.
// extent = [minLon, minLat, maxLon, maxLat]
function _cellsInView(cells, extent) {
var minLon = extent[0], minLat = extent[1], maxLon = extent[2], maxLat = extent[3];
return cells.filter(function(c) {
if (!c.bbox || c.bbox.length < 4) return false;
var w = c.bbox[0], s = c.bbox[1], e = c.bbox[2], n = c.bbox[3];
// Filtro AABB: ¿hay solapamiento?
return w <= maxLon && e >= minLon && s <= maxLat && n >= minLat;
});
}
// Convierte el extent del mapa OL (EPSG:3857) a [minLon,minLat,maxLon,maxLat]
function _mapExtentLonLat() {
try {
var olMap = GPSMap.getOLMap();
if (!olMap) return null;
var sz = olMap.getSize();
if (!sz || !sz[0] || !sz[1]) return null; // mapa sin renderizar aún
var ext3857 = olMap.getView().calculateExtent(sz);
if (!ext3857 || isNaN(ext3857[0])) return null;
var sw = ol.proj.toLonLat([ext3857[0], ext3857[1]]);
var ne = ol.proj.toLonLat([ext3857[2], ext3857[3]]);
if (isNaN(sw[0]) || isNaN(ne[0])) return null;
return [sw[0], sw[1], ne[0], ne[1]];
} catch(e) {
console.warn('[ChartLayer] _mapExtentLonLat error:', e.message || e);
return null;
}
}
// ── Nivel de detalle ─────────────────────────────────────────────────────
// 'basic' → aids + land + DEPARE fills (sin líneas de veril)
// 'medium' → + veriles (DEPCNT) + hazards (4 tipos)
// 'advanced' → capas seleccionables via modal
var _detailLevel = 'basic';
// Flags por capa para nivel 'advanced' (controlados por modal de capas)
var _advLayers = {
// ── Profundidades ──────────────────────────────────────────────────────
depare: true, // áreas de profundidad (fills azules)
depcnt: true, // veriles / isobatas (líneas)
soundg: false, // sondas (texto de profundidad)
hazards: true, // naufragios, rocas, obstrucciones
zones: false, // zonas restringidas, fondeo, tráfico
// ── Tierra / costa ────────────────────────────────────────────────────
coalne: true, // COALNE — trazo de línea de costa
lndare: false, // LNDARE — bordes del polígono tierra (genera líneas diagonales)
landmask: true, // LANDMASK — relleno beige de tierra S-57
buaare: false, // BUAARE — límites de zonas urbanas
// ── Mapa base ─────────────────────────────────────────────────────────
osm: true, // OSM tiles (mapa base)
};
function _dataTypes() {
if (_detailLevel === 'advanced') {
// Construir lista según flags individuales
var types = ['features', 'land'];
if (_advLayers.depare || _advLayers.depcnt || _advLayers.soundg) types.push('depths');
if (_advLayers.hazards) types.push('hazards');
if (_advLayers.zones) types.push('zones');
return types;
}
if (_detailLevel === 'medium')
return ['features', 'land', 'depths', 'hazards'];
// basic: aids + tierra + DEPARE fills (sin líneas — depLineStyle las filtra)
return ['features', 'land', 'depths'];
}
function setLayerVisibility(opts) {
Object.keys(opts).forEach(function(k) {
if (k in _advLayers) _advLayers[k] = !!opts[k];
});
// Refrescar estilos inmediatamente
depthSource.changed();
soundgSource.changed();
landSource.changed();
hazardLayer.setVisible(_advLayers.hazards);
zoneLayer.setVisible(_advLayers.zones);
// OSM — toggle visibilidad del tile layer
var olMap = GPSMap.getOLMap();
if (olMap) {
olMap.getLayers().forEach(function(lyr) {
if (lyr && lyr.getSource && lyr.getSource() instanceof ol.source.OSM) {
lyr.setVisible(_advLayers.osm);
}
});
}
// Recargar solo si se activó un tipo que no estaba cargado
_loading = false;
_loadedCellIds.clear();
loadAll();
}
function getAdvLayers() { return Object.assign({}, _advLayers); }
// ── Public API ────────────────────────────────────────────────────────────
var _loadedCellIds = new Set(); // evita recargar celdas ya cargadas
var _loading = false; // guard anti-reentrada: QWebChannel es secuencial
async function loadAll() {
_attachLayers();
if (!window.py) { console.warn('[ChartLayer] bridge not ready'); return; }
if (_loading) { console.log('[ChartLayer] carga en curso, ignorando llamada duplicada'); return; }
_loading = true;
// Obtener lista de celdas del bridge (JSON pequeño — sin problema de tamaño)
var allCells;
try {
allCells = JSON.parse(await _py('get_chart_cells'));
} catch(e) {
console.warn('[ChartLayer] get_chart_cells failed:', e.message || e);
_loading = false; return;
}
if (!allCells || !allCells.length) {
console.warn('[ChartLayer] no cells installed');
_loading = false; return;
}
var cellsWithBbox = allCells.filter(function(c){ return c.bbox; });
// Filtro de viewport — siempre usar extent si está disponible
var extent = _mapExtentLonLat();
var cells;
if (extent) {
cells = _cellsInView(cellsWithBbox, extent);
console.log('[ChartLayer] nivel:', _detailLevel,
'| extent:', extent.map(function(v){return v.toFixed(2);}).join(','),
'→', cells.length, '/', cellsWithBbox.length, 'celdas en view');
} else {
// Fallback: sin extent válido (primer frame), cargar todas
cells = cellsWithBbox;
console.log('[ChartLayer] nivel:', _detailLevel,
'| sin extent — todas las celdas con bbox:', cells.length);
}
// Filtrar celdas ya cargadas con el nivel actual
// (al cambiar nivel se hace reloadAll que limpia _loadedCellIds)
cells = cells.filter(function(c){ return !_loadedCellIds.has(c.id); });
if (!cells.length) {
console.log('[ChartLayer] sin celdas nuevas que cargar');
_loading = false; return;
}
var types = _dataTypes();
console.log('[ChartLayer] cargando', cells.length, 'celdas × [' + types.join(', ') + ']:',
cells.slice(0,6).map(function(c){ return c.id; }).join(', '),
cells.length > 6 ? '...' : '');
// Carga secuencial por celda/tipo — cada mensaje QWebChannel < ~700 KB
try {
var nFeat=0, nLand=0, nDep=0, nSoundg=0, nHaz=0, nZone=0;
if (types.indexOf('features') >= 0) {
var feat = await _loadAllCells(cells, 'features', true);
nFeat = _addGeoJSON(encSource, feat) || 0;
}
if (types.indexOf('land') >= 0) {
var land = await _loadAllCells(cells, 'land', false);
nLand = _addGeoJSON(landSource, land) || 0;
}
if (types.indexOf('hazards') >= 0) {
var haz = await _loadAllCells(cells, 'hazards', false);
nHaz = _addGeoJSON(hazardSource, haz) || 0;
}
if (types.indexOf('depths') >= 0) {
var dep = await _loadAllCells(cells, 'depths', false);
var depCounts = _addDepthsGeoJSON(dep); // split: SOUNDG → soundgSource
nDep = depCounts[0]; nSoundg = depCounts[1];
}
if (types.indexOf('zones') >= 0) {
var zone = await _loadAllCells(cells, 'zones', false);
nZone = _addGeoJSON(zoneSource, zone) || 0;
}
cells.forEach(function(c){ _loadedCellIds.add(c.id); });
console.log('[ChartLayer] LISTO — aids:', nFeat, 'land:', nLand,
'depths:', nDep, 'soundg:', nSoundg, 'hazards:', nHaz, 'zones:', nZone);
} finally {
_loading = false;
}
}
async function reloadAll() {
// Limpiar fuentes y set de celdas cargadas, luego recargar el viewport
[landSource, zoneSource, depthSource, soundgSource, encSource, hazardSource].forEach(function(s){ s.clear(); });
_loadedCellIds.clear();
_loading = false; // resetear guard para que loadAll pueda arrancar
return loadAll();
}
function setDetailLevel(lvl) {
if (lvl !== 'basic' && lvl !== 'medium' && lvl !== 'advanced') return;
_detailLevel = lvl;
['basic', 'medium', 'advanced'].forEach(function(l) {
var btn = document.getElementById('enc-lvl-' + l);
if (btn) btn.classList.toggle('active', l === lvl);
});
reloadAll();
}
// ── setChartMode — recolorea capas S-57 por modo (sin tocar ayudas IALA) ──
// Mismo principio que AR ECDIS presentation.js:
// · DEPARE/LNDARE/DEPCNT/SOUNDG → fill color cambia por modo
// · BOYLAT/BOYCAR/LIGHTS/etc. → colores IALA, NUNCA cambian
// · OSM tiles → opacidad reducida en modos oscuros
function setChartMode(mode) {
var validModes = ['night', 'dusk', 'day-std', 'day'];
if (validModes.indexOf(mode) < 0) return;
_chartMode = mode;
// Recolorar capas de área/profundidad — los SVG de ayudas no se tocan
depthSource.changed();
soundgSource.changed(); // sondas en capa dedicada también se recoloran
// landLayer usa _landStyle (función) que lee _chartMode dinámicamente — solo changed()
landSource.changed();
// Ajustar opacidad OSM — oscuro en modos noche/dusk, normal en día
var C = _modeColors();
var olMap = GPSMap.getOLMap();
if (olMap) {
olMap.getLayers().forEach(function(lyr) {
if (lyr && lyr.getSource && lyr.getSource() instanceof ol.source.OSM) {
lyr.setOpacity(C.osmOpacity);
}
});
}
console.log('[ChartLayer] modo presentación:', mode, '| OSM opacity:', C.osmOpacity);
}
// ── Hover tooltip & click info panel ─────────────────────────────────────
// Tooltip: div flotante sobre el mapa, aparece solo en hover.
// Panel: sección en panel derecho, aparece al hacer click en una ayuda.
var _ttEl = null; // tooltip DOM element
function _ensureTT() {
if (_ttEl) return _ttEl;
var wrap = document.getElementById('map-wrap');
if (!wrap) return null;
_ttEl = document.createElement('div');
_ttEl.className = 'enc-tooltip';
_ttEl.style.display = 'none';
wrap.appendChild(_ttEl);
return _ttEl;
}
// Texto HTML escapado
function _esc(s) {
return String(s || '').replace(/&/g, '&').replace(//g, '>');
}
// Formato grados-minutos "11°01.234'N"
function _fmtDeg(deg, pPos, pNeg) {
var a = Math.abs(deg), d = Math.floor(a), m = (a - d) * 60;
return d + '°' + m.toFixed(3) + '’' + (deg < 0 ? pNeg : pPos);
}
// Extraer info estructurada de un feature OL
function _featInfo(f) {
var aid = f.get('aid_type') || '';
var layer = (f.get('layer') || '').toUpperCase();
var name = f.get('name') || '';
var light = f.get('light_desc') || '';
var cat = f.get('category') || '';
var region = f.get('cell_region') || 'B';
var colours= f.get('colours') || [];
var depth = f.get('depth');
var dmax = f.get('depth_max');
var TYPE_LABELS = {
'LATERAL_PORT': 'Lateral Port', 'LATERAL_STBD': 'Lateral Starboard',
'LATERAL_PREF_PORT': 'Pref. Ch. Port', 'LATERAL_PREF_STBD': 'Pref. Ch. Stbd',
'LATERAL_UNKNOWN': 'Lateral Unknown', 'CARDINAL_N': 'Cardinal N',
'CARDINAL_S': 'Cardinal S', 'CARDINAL_E': 'Cardinal E',
'CARDINAL_W': 'Cardinal W', 'CARDINAL_UNKNOWN': 'Cardinal',
'ISOLATED_DANGER': 'Isolated Danger', 'SAFE_WATER': 'Safe Water',
'SPECIAL': 'Special Mark', 'LIGHT_POINT': 'Light',
'LANDMARK': 'Landmark', 'RACON': 'Racon',
'BEACON_GENERIC': 'Beacon', 'BUOY_GENERIC': 'Buoy',
'UNKNOWN': 'Unknown',
};
var CAT = { wreck:'Wreck', rock:'Underwater Rock', obstruction:'Obstruction' };
var CNAMES = {1:'White',2:'Black',3:'Red',4:'Green',5:'Blue',6:'Yellow',
7:'Grey',8:'Brown',9:'Amber',10:'Violet',11:'Orange',12:'Magenta'};
var typeLabel = TYPE_LABELS[aid] || CAT[cat] || layer || 'Feature';
var objClass = layer.startsWith('BCN') ? 'Beacon' : (layer.indexOf('BOY') >= 0 ? 'Buoy' : '');
if (objClass && typeLabel.indexOf(objClass) < 0) typeLabel = typeLabel + ' ' + objClass;
var regionStr = region === 'A' ? 'IALA-A' : 'IALA-B';
var colorStr = colours.map(function(c) { return CNAMES[c] || ''; }).filter(Boolean).join('/');
// Coordenadas del objeto
var coords = null;
try {
var geom = f.getGeometry();
if (geom) {
var pt = geom.getType() === 'Point'
? geom.getCoordinates()
: ol.extent.getCenter(geom.getExtent());
if (pt) coords = ol.proj.toLonLat(pt);
}
} catch(e) {}
return { aid, layer, name, light, cat, region, regionStr, colours, colorStr,
depth, dmax, coords, typeLabel };
}
// Posicionar tooltip cerca del cursor (evita salirse del mapa)
function _posTT(px, py) {
if (!_ttEl) return;
var wrap = document.getElementById('map-wrap');
var W = wrap ? wrap.offsetWidth : 800;
var H = wrap ? wrap.offsetHeight : 600;
var ew = _ttEl.offsetWidth || 180;
var eh = _ttEl.offsetHeight || 52;
var left = px + 16, top = py - Math.round(eh / 2);
if (left + ew > W - 8) left = px - ew - 12;
if (top < 4) top = 4;
if (top + eh > H - 4) top = H - eh - 4;
_ttEl.style.left = left + 'px';
_ttEl.style.top = top + 'px';
}
// Capas que NO deben mostrar tooltip (demasiado ruido)
var _noTooltipLayers = { DEPARE:1, LANDMASK:1, LNDARE:1, DEPCNT:1, SOUNDG:1 };
function _showTooltip(f, px, py) {
var el = _ensureTT(); if (!el) return;
var info = _featInfo(f);
if (_noTooltipLayers[info.layer]) { _hideTooltip(); return; }
var html = '
' + _esc(info.typeLabel) + '
';
if (info.name) html += '
' + _esc(info.name) + '
';
if (info.light) html += '
💡 ' + _esc(info.light) + '
';
else if (info.colorStr) html += '
' + _esc(info.colorStr) + '
';
el.innerHTML = html;
el.style.display = 'block';
// Posicionar después de renderizar (para leer offsetWidth real)
setTimeout(function() { _posTT(px, py); }, 0);
}
function _hideTooltip() {
if (_ttEl) _ttEl.style.display = 'none';
}
// ── Info panel (panel derecho) ────────────────────────────────────────────
function _showInfoPanel(f) {
var el = document.getElementById('rp-feat-info');
if (!el) return;
var info = _featInfo(f);
// Color del badge según tipo de ayuda
var bc = '#607d8b';
if (info.aid.indexOf('PORT') >= 0) bc = '#009900';
if (info.aid.indexOf('STBD') >= 0) bc = '#cc1111';
if (info.aid.indexOf('CARDINAL') >= 0) bc = '#f0c000';
if (info.aid === 'ISOLATED_DANGER') bc = '#cc1111';
if (info.aid === 'SAFE_WATER') bc = '#ee2222';
if (info.aid === 'LIGHT_POINT') bc = '#cc00cc';
if (info.aid === 'LANDMARK') bc = '#8b6914';
if (info.cat === 'wreck') bc = '#e53935';
if (info.cat === 'rock') bc = '#f59e0b';
function row(lbl, val, cls) {
if (!val && val !== 0) return '';
return '
' +
'' + lbl + '' +
'' + _esc(String(val)) + '
';
}
var rows = '';
if (info.name) rows += row('Name', info.name);
if (info.light) rows += row('Light', info.light, 'fi-light');
if (info.colorStr) rows += row('Colors', info.colorStr);
if (info.aid && (info.aid.indexOf('LATERAL') >= 0 || info.aid.indexOf('CARDINAL') >= 0))
rows += row('Region', info.regionStr);
if (info.cat) rows += row('Cat.', info.cat);
if (info.dmax != null) rows += row('Depth max', info.dmax.toFixed(1) + ' m');
if (info.depth != null) rows += row('Depth', info.depth.toFixed(1) + ' m');
if (info.coords) {
rows += row('Lat', _fmtDeg(info.coords[1], 'N', 'S'));
rows += row('Lon', _fmtDeg(info.coords[0], 'E', 'W'));
}
el.innerHTML =
'
' +
'■' +
'' + _esc(info.typeLabel) + '' +
'' +
'
' +
(rows ? '
' + rows + '
'
: '
No hay datos adicionales
');
el.style.display = 'block';
}
function hideFeatureInfo() {
var el = document.getElementById('rp-feat-info');
if (el) el.style.display = 'none';
}
return { loadAll, reloadAll, setDetailLevel, setLayerVisibility, getAdvLayers,
setChartMode, hideFeatureInfo,
encSource, depthSource, hazardSource, zoneSource, landSource };
})();