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