'use strict'; const MAP_CENTER_LAT = 25.7743; const MAP_CENTER_LON = -80.1937; const MAP_ZOOM = 11; // IALA region: 'A' = Europe/Africa/Asia (red=port), 'B' = Americas/Japan (green=port) // Can be changed from settings; default B for Colombian Caribbean window.IALA_REGION = localStorage.getItem('ams_iala') || 'B'; // ── Capas ────────────────────────────────────────────────────────────────── // Capa base OSM — cobertura mundial completa, todos los niveles de zoom const osmLayer = new ol.layer.Tile({ source: new ol.source.OSM({ crossOrigin: 'anonymous' }), opacity: 1.0, }); // Placeholder — se mantiene la variable para no romper applyNightMode ni el array de capas const oceanRefLayer = new ol.layer.Tile({ source: new ol.source.OSM({ crossOrigin: 'anonymous' }), opacity: 0, visible: false, }); const seaMapLayer = new ol.layer.Tile({ source: new ol.source.XYZ({ url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', crossOrigin: 'anonymous', }), opacity: 1.0, }); const vesselsSource = new ol.source.Vector(); const aidsSource = new ol.source.Vector(); window.vesselsSource = vesselsSource; const vesselsLayer = new ol.layer.Vector({ source: vesselsSource, zIndex: 10, declutter: true }); const aidsLayer = new ol.layer.Vector({ source: aidsSource, zIndex: 20, declutter: true }); // ── S-57 ENC vector layer ───────────────────────────────────────────────── const encSource = new ol.source.Vector(); const encLayer = new ol.layer.Vector({ source: encSource, zIndex: 19, // S-57 aids on top of everything except live AIS aids style: encStyle, // 1200 m/px ≈ zoom 7 — below that, don't render ENC symbols at all. // Per-cell band ranges inside _cellShouldRender refine visibility further. // Band 0 (custom cells like BARRANQUILLA) stay visible down to this limit. maxResolution: 1200, declutter: true, }); // ── Bathymetry — split into 2 layers so SOUNDG (numbers) renders ON TOP of // terrain & buildings (ECDIS S-52 layer order). DEPARE/DEPCNT/LANDMASK stay // at low zIndex underneath the land mass. const depthSource = new ol.source.Vector(); const depthLayer = new ol.layer.Vector({ source: depthSource, zIndex: 2, // above OSM/seamap, below land/aids visible: true, style: depthStyle, declutter: true, // Painters' order: DEPARE deepest-first so shallower areas paint on top, // then LANDMASK (covers any residual depth bleed), then DEPCNT contours. renderOrder: (a, b) => { const RANK = { DEPARE: 0, LANDMASK: 1, DEPCNT: 2 }; const ra = RANK[a.get('layer')] ?? 4; const rb = RANK[b.get('layer')] ?? 4; if (ra !== rb) return ra - rb; if (a.get('layer') === 'DEPARE') { return (b.get('depth_max') || 0) - (a.get('depth_max') || 0); } return 0; }, }); // SOUNDG depth numbers — rendered above terrain & buildings but BELOW the // aids/hazards layers, so buoys and wrecks always stay visible over the // soundings (operational priority: navigation marks > depth numbers). const soundSource = new ol.source.Vector(); const soundLayer = new ol.layer.Vector({ source: soundSource, zIndex: 4.5, visible: true, style: soundStyle, declutter: true, }); // ── Zones layer (RESARE, CTNARE, ACHARE, TSSLPT, FAIRWY…) ──────────────────── // zIndex 3: drawn ON the water (above depths) but BELOW land polygons const zoneSource = new ol.source.Vector(); const zoneLayer = new ol.layer.Vector({ source: zoneSource, zIndex: 3, style: zoneStyle, visible: true, }); // ── Land / terrain layer (LNDARE polygons, BUAARE, COALNE, structures) ─────── // zIndex 4: above zones so land covers channel overlays where there's terrain const landSource = new ol.source.Vector(); const landLayer = new ol.layer.Vector({ source: landSource, zIndex: 4, style: landStyle, visible: true, renderOrder: (a, b) => { const RANK = { LNDARE: 0, BUAARE: 1, COALNE: 2 }; const ra = RANK[a.get('layer')] ?? 3; // points (BRIDGE, HRBFAC, BUISGL…) on top const rb = RANK[b.get('layer')] ?? 3; return ra - rb; }, }); // ── Hazards layer (WRECKS, OBSTRN, UWTROC) ─────────────────────────────────── const hazardSource = new ol.source.Vector(); const hazardLayer = new ol.layer.Vector({ source: hazardSource, zIndex: 6, style: hazardStyle, maxResolution: 150, declutter: true, visible: true, }); // ── Canvas symbol cache ─────────────────────────────────────────────────── const _iconCache = {}; window._iconCache = _iconCache; // expose for settings live-update function _cachedIcon(key, drawFn) { if (!_iconCache[key]) _iconCache[key] = drawFn(); return _iconCache[key]; } // ── Ship canvas ─────────────────────────────────────────────────────────── function _drawShip(color, sz = 32) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; ctx.save(); ctx.translate(h, h); // hull ctx.beginPath(); ctx.moveTo(0, -h + 3); // bow ctx.bezierCurveTo(6, -4, 6, 4, 5, h - 3); // starboard ctx.lineTo(-5, h - 3); // stern ctx.bezierCurveTo(-6, 4, -6, -4, 0, -h + 3); // port ctx.closePath(); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.stroke(); // bridge rect ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(-3, -2, 6, 6); // bow arrow ctx.beginPath(); ctx.moveTo(0, -h + 2); ctx.lineTo(3, -h + 8); ctx.lineTo(-3, -h + 8); ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fill(); ctx.restore(); return c; } // ── Aid canvas helpers ──────────────────────────────────────────────────── function _drawCircleDiamond(fill, stroke, sz = 22) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // circle body ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); // inner diamond ctx.beginPath(); ctx.moveTo(h, 3); ctx.lineTo(sz - 3, h); ctx.lineTo(h, sz - 3); ctx.lineTo(3, h); ctx.closePath(); ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 1.5; ctx.stroke(); return c; } function _drawLighthouse(fill, sz = 26) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // 8-point star ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = (i * Math.PI) / 4; const r = i % 2 === 0 ? h - 1 : (h - 1) * 0.45; const x = h + Math.cos(a - Math.PI / 2) * r; const y = h + Math.sin(a - Math.PI / 2) * r; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); // center dot ctx.beginPath(); ctx.arc(h, h, 3, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); return c; } function _drawDiamond(fill, stroke, sz = 22) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; ctx.beginPath(); ctx.moveTo(h, 1); ctx.lineTo(sz - 1, h); ctx.lineTo(h, sz - 1); ctx.lineTo(1, h); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); return c; } function _drawTriangle(fill, stroke, sz = 22, inverted = false) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); ctx.beginPath(); if (!inverted) { ctx.moveTo(sz / 2, 1); ctx.lineTo(sz - 1, sz - 1); ctx.lineTo(1, sz - 1); } else { ctx.moveTo(1, 1); ctx.lineTo(sz - 1, 1); ctx.lineTo(sz / 2, sz - 1); } ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); return c; } function _drawCardinalBuoy(quadrant, sz = 24) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // Black/yellow banding by quadrant (IALA) const topBlack = ['N','NE','E','NW'].includes(quadrant); ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.fillStyle = topBlack ? '#000' : '#f5c518'; ctx.fill(); ctx.beginPath(); ctx.arc(h, h, h - 2, Math.PI, 0); ctx.fillStyle = topBlack ? '#f5c518' : '#000'; ctx.fill(); ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); // Arrows indicating safe water side ctx.fillStyle = '#fff'; ctx.font = `bold ${sz - 8}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const arrows = {N:'↑',S:'↓',E:'→',W:'←',NE:'↗',NW:'↖',SE:'↘',SW:'↙'}; ctx.fillText(arrows[quadrant] || '◆', h, h); return c; } function _drawSpecialMark(fill, sz = 20) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // X shape (special mark — yellow cross) ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(5, 5); ctx.lineTo(sz - 5, sz - 5); ctx.moveTo(sz - 5, 5); ctx.lineTo(5, sz - 5); ctx.stroke(); return c; } // ── S-57 colour code → CSS colour ───────────────────────────────────────── const S57_CSS = { 1:'#ffffff', 2:'#111111', 3:'#e53935', 4:'#2e7d32', 5:'#1565c0', 6:'#f9a825', 7:'#78909c', 8:'#6d4c41', 9:'#ff8f00', 10:'#7b1fa2', 11:'#ef6c00', 12:'#e91e63', }; function _s57css(code) { return S57_CSS[code] || '#78909c'; } /* ═══════════════════════════════════════════════════════════════════════════ IALA Nautical Symbols — exact silhouettes per IALA Maritime Buoyage System Reference: IALA Publication No.1, IHO INT-1, IALA Region A & B charts Layout per symbol (top→bottom): topmark → staff → body → waterline ═══════════════════════════════════════════════════════════════════════════ */ function _mkC(sz) { const c = document.createElement('canvas'); c.width = sz; c.height = sz; return c; } function _wl(ctx, cx, y, hw) { // waterline ctx.strokeStyle = '#1a6bb5'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx - hw, y); ctx.lineTo(cx + hw, y); ctx.stroke(); } function _st(ctx, cx, y1, y2) { // staff ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, y1); ctx.lineTo(cx, y2); ctx.stroke(); } // Topmark: cone pointing UP ▲ function _tmConeUp(ctx, cx, y0, w, h, fill) { ctx.beginPath(); ctx.moveTo(cx, y0); ctx.lineTo(cx + w/2, y0 + h); ctx.lineTo(cx - w/2, y0 + h); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); } // Topmark: cone pointing DOWN ▼ function _tmConeDown(ctx, cx, y0, w, h, fill) { ctx.beginPath(); ctx.moveTo(cx - w/2, y0); ctx.lineTo(cx + w/2, y0); ctx.lineTo(cx, y0 + h); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); } // Topmark: sphere ● function _tmSphere(ctx, cx, cy, r, fill) { ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); } // Topmark: X cross function _tmX(ctx, cx, cy, r, col) { ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx-r, cy-r); ctx.lineTo(cx+r, cy+r); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx+r, cy-r); ctx.lineTo(cx-r, cy+r); ctx.stroke(); } // ── True-3D colour helpers ──────────────────────────────────────────────── function _h2r(hex) { // hex or rgb() → [r,g,b] if (typeof hex === 'string' && hex.startsWith('rgb')) { const m = hex.match(/\d+/g); return m ? [+m[0], +m[1], +m[2]] : [0, 0, 0]; } hex = (hex || '').replace('#',''); if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)]; } function _lighten3D(hex, f = 0.45) { const [r,g,b] = _h2r(hex); return `rgb(${Math.min(255,Math.round(r+(255-r)*f))},${Math.min(255,Math.round(g+(255-g)*f))},${Math.min(255,Math.round(b+(255-b)*f))})`; } function _darken3D(hex, f = 0.45) { const [r,g,b] = _h2r(hex); return `rgb(${Math.round(r*(1-f))},${Math.round(g*(1-f))},${Math.round(b*(1-f))})`; } // Cylindrical side gradient: bright left → main → dark right function _cylGrad(ctx, x, w, yMid, col) { const g = ctx.createLinearGradient(x, yMid, x + w, yMid); g.addColorStop(0, _lighten3D(col, 0.58)); g.addColorStop(0.22, _lighten3D(col, 0.18)); g.addColorStop(0.55, col); g.addColorStop(0.82, _darken3D(col, 0.32)); g.addColorStop(1, _darken3D(col, 0.55)); return g; } // Conical gradient: same as cyl but stronger contrast function _coneGrad(ctx, x, w, yMid, col) { const g = ctx.createLinearGradient(x, yMid, x + w, yMid); g.addColorStop(0, _lighten3D(col, 0.65)); g.addColorStop(0.20, _lighten3D(col, 0.20)); g.addColorStop(0.55, col); g.addColorStop(1, _darken3D(col, 0.60)); return g; } // Banded fill for multi-colour shapes, each band individually gradienterd function _fillBands3D(ctx, pathFn, bx, by, bw, bh, cssColours, gradFn) { ctx.save(); pathFn(ctx); ctx.clip(); if (!cssColours.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); } else { const bandH = bh / cssColours.length; cssColours.forEach((col, i) => { const y0 = by + i * bandH; ctx.fillStyle = gradFn(ctx, bx, bw, y0 + bandH / 2, col); ctx.fillRect(bx, y0, bw, bandH + 1); }); } ctx.restore(); } // Kept for backward compat with cardinal / isd / saw helpers function _fillBands(ctx, pathFn, bbox, cssColours) { const [bx, by, bw, bh] = bbox; ctx.save(); pathFn(ctx); ctx.clip(); if (!cssColours.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); } else { const bandH = bh / cssColours.length; cssColours.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(bx, by + i * bandH, bw, bandH + 0.6); }); } ctx.restore(); } // Kept for backward compat function _apply3DShade(ctx, pathFn, bbox) { const [bx, by, bw, bh] = bbox; ctx.save(); pathFn(ctx); ctx.clip(); const g = ctx.createLinearGradient(bx, by+bh/2, bx+bw, by+bh/2); g.addColorStop(0,'rgba(255,255,255,0.38)'); g.addColorStop(0.3,'rgba(255,255,255,0.06)'); g.addColorStop(0.65,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.32)'); ctx.fillStyle = g; ctx.fillRect(bx, by, bw, bh); ctx.restore(); } // ── IALA fallback colours when S-57 COLOUR is missing ───────────────────── // Returns S-57 colour CODES (not CSS) so they go through normal banding. function _ialaLateralColours(catlam, region) { const ialaB = (region || 'B') === 'B'; const G = 4, R = 3; const port = ialaB ? G : R; // port-hand colour const stbd = ialaB ? R : G; // starboard-hand colour if (catlam === 1) return [port]; if (catlam === 2) return [stbd]; if (catlam === 3) return [port, stbd, port]; // pref-channel-to-stbd: port w/ stbd band if (catlam === 4) return [stbd, port, stbd]; // pref-channel-to-port: stbd w/ port band return []; } // Fill a closed path with N horizontal bands (used for multi-colour buoys). function _fillBands(ctx, pathFn, bbox, cssColours) { const [bx, by, bw, bh] = bbox; ctx.save(); pathFn(ctx); ctx.clip(); if (cssColours.length === 0) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); } else { const bandH = bh / cssColours.length; cssColours.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(bx, by + i * bandH, bw, bandH + 0.6); }); } ctx.restore(); } // ══════════════════════════════════════════════════════════════════════════ // TRUE-3D BUOY SYMBOLS — geometry is built in 3 dimensions: // • Cylinders use side-rect + top-ellipse + bottom-ellipse // • Cones use face-triangle + base-ellipse + highlight streak // • Spheres use radial gradient with upper-left highlight // • Barrels use two bezier curves + elliptical ends // • Topmarks are correct per IALA: PORT = square cube, STBD = cone up // opts: {colours, boyshp, catlam, region, sz?, hasLight} // BOYSHP: 1=conical 2=can 3=sphere 4=pillar 5=spar 6=barrel 7=super-buoy // ══════════════════════════════════════════════════════════════════════════ function _encBuoyCanvas(opts) { const sz = opts.sz || 52; const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; let cc = (opts.colours || []).slice(); if (!cc.length && opts.catlam) cc = _ialaLateralColours(opts.catlam, opts.region); const css = cc.map(_s57css); const c1 = css[0] || '#78909c'; const ialaB = (opts.region || 'B') === 'B'; // ── Glow helper: apply outer glow on shape outlines via shadowBlur ────── // Call before strokeRect/stroke; reset after. col = CSS colour of the glow. const _glow = (col, blur = 4) => { ctx.shadowBlur = blur; ctx.shadowColor = col; }; const _noGlow = () => { ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; }; // ── Layout ────────────────────────────────────────────────────────────── const wlY = sz * 0.92; // waterline const bBot = sz * 0.88; // body bottom (waterline region) const bTop = sz * 0.52; // body top (pushed down for topmark room) // stTop must leave room for topmark above it (topmark height ≈ bW * 0.40) const _tmRoom = sz * 0.52 * 0.40; // topmark height reservation const stTop = opts.hasLight ? Math.max(sz * 0.20, _tmRoom + sz * 0.03) : Math.max(sz * 0.24, _tmRoom + sz * 0.03); const bW = sz * 0.52; // body width const bH = bBot - bTop; // body height const eRy = bH * 0.095; // ellipse y-radius for cylinder caps const bs = opts.boyshp, cl = opts.catlam; const isCan = bs === 2 || (!bs && (cl === 1 || cl === 3)); const isCone = bs === 1 || (!bs && (cl === 2 || cl === 4)); const isSph = bs === 3; const isSpar = bs === 5; const isBar = bs === 6; const isSuper = bs === 7; // ── Shared draw helpers ───────────────────────────────────────────────── // Glow colour: lighten primary colour, used for outline glow const glowCol = c1 ? _lighten3D(c1, 0.55) : 'rgba(255,255,255,0.7)'; const outln = (lw = 0.9) => { _noGlow(); ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = lw; ctx.stroke(); }; const outlnGlow = (lw = 1.2) => { _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = lw; ctx.stroke(); _noGlow(); }; // Draw a cylinder: sides + bottom ellipse + top ellipse const drawCylinder = (x, topY, w, h, colList) => { const ery = h * 0.095; // Side face (banded) _fillBands3D(ctx, cc => { cc.beginPath(); cc.rect(x, topY, w, h); }, x, topY, w, h, colList, _cylGrad); // Glow outline on side _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 1.0; ctx.strokeRect(x, topY, w, h); _noGlow(); ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 0.6; ctx.strokeRect(x, topY, w, h); // Bottom ellipse (shadow side) ctx.beginPath(); ctx.ellipse(x + w/2, topY + h, w/2, ery, 0, 0, Math.PI * 2); ctx.fillStyle = _darken3D(colList[colList.length-1] || c1, 0.5); ctx.fill(); outln(0.5); // Top ellipse with radial highlight const tg = ctx.createRadialGradient(x + w*0.3, topY, 1, x + w/2, topY, w/2); tg.addColorStop(0, 'rgba(255,255,255,0.80)'); tg.addColorStop(0.5, _lighten3D(colList[0] || c1, 0.25)); tg.addColorStop(1, colList[0] || c1); ctx.beginPath(); ctx.ellipse(x + w/2, topY, w/2, ery, 0, 0, Math.PI * 2); ctx.fillStyle = tg; ctx.fill(); outln(0.5); }; // Draw a cone: face + base ellipse + highlight const drawCone = (cx2, apexY, baseY, w, colList) => { const ery = (baseY - apexY) * 0.10; const bx = cx2 - w/2; // Body _fillBands3D(ctx, cc => { cc.beginPath(); cc.moveTo(cx2, apexY); cc.lineTo(cx2+w/2, baseY); cc.lineTo(cx2-w/2, baseY); cc.closePath(); }, bx, apexY, w, baseY - apexY, colList, _coneGrad); // Glow then outline ctx.beginPath(); ctx.moveTo(cx2, apexY); ctx.lineTo(cx2+w/2, baseY); ctx.lineTo(cx2-w/2, baseY); ctx.closePath(); _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.40)'; ctx.lineWidth = 1.0; ctx.stroke(); _noGlow(); ctx.beginPath(); ctx.moveTo(cx2, apexY); ctx.lineTo(cx2+w/2, baseY); ctx.lineTo(cx2-w/2, baseY); ctx.closePath(); outln(0.9); // Base ellipse ctx.beginPath(); ctx.ellipse(cx2, baseY, w/2, ery, 0, 0, Math.PI * 2); ctx.fillStyle = _darken3D(colList[colList.length-1] || c1, 0.40); ctx.fill(); outln(0.5); // Left-face highlight streak ctx.beginPath(); ctx.moveTo(cx2 - w * 0.05, apexY + (baseY-apexY)*0.08); ctx.lineTo(cx2 - w * 0.28, baseY - ery); ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = w * 0.07; ctx.stroke(); }; // Draw a sphere with radial gradient (multi-colour = horizontal stripes) const drawSphere = (cx2, cy2, r, colList) => { // Stripes via clip if (colList.length > 1) { const bandH = r * 2 / colList.length; colList.forEach((col, i) => { ctx.save(); ctx.beginPath(); ctx.rect(cx2-r, cy2-r + i*bandH, r*2, bandH+1); ctx.clip(); ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); ctx.fillStyle = col; ctx.fill(); ctx.restore(); }); } else { ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); ctx.fillStyle = colList[0] || '#78909c'; ctx.fill(); } // 3D highlight overlay ctx.save(); ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); ctx.clip(); const rg = ctx.createRadialGradient(cx2-r*0.38, cy2-r*0.38, r*0.04, cx2, cy2, r); rg.addColorStop(0, 'rgba(255,255,255,0.72)'); rg.addColorStop(0.30,'rgba(255,255,255,0.18)'); rg.addColorStop(0.65,'rgba(0,0,0,0.00)'); rg.addColorStop(1, 'rgba(0,0,0,0.42)'); ctx.fillStyle = rg; ctx.fillRect(cx2-r, cy2-r, r*2, r*2); ctx.restore(); ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); outln(0.8); }; // ── TOPMARKS per IALA ────────────────────────────────────────────────── // PORT mark → square can topmark (cube face, viewed from front) const drawTopmarkCan = (tx, ty, w, col) => { const h = w; // SQUARE — same width and height drawCylinder(tx - w/2, ty - h, w, h, [col]); }; // STBD mark → conical topmark point-up const drawTopmarkCone = (tx, ty, w, col) => { const h = w * 0.9; drawCone(tx, ty - h, ty, w, [col]); }; // Generic cone-up topmark (for other buoys) const drawTopmarkConeSmall = (tx, ty, w, col) => { const h = w * 0.7; drawCone(tx, ty - h, ty, w, [col]); }; // ── DRAW by shape ────────────────────────────────────────────────────── if (isCan) { // ── CAN (Cilíndrica) — 3D cylinder ──────────────────────── drawCylinder(cx - bW/2, bTop, bW, bH, css.length ? css : [c1]); _st(ctx, cx, bTop - eRy, stTop); // PORT topmark = square can (same width as topmark reservation) drawTopmarkCan(cx, stTop, bW * 0.42, c1); } else if (isCone) { // ── CONE (Cónica) — 3D cone ──────────────────────────────── drawCone(cx, bTop, bBot, bW, css.length ? css : [c1]); _st(ctx, cx, bTop, stTop); // STBD topmark = conical point-up drawTopmarkCone(cx, stTop, bW * 0.44, c1); } else if (isSph) { // ── SPHERE (Esférica) — 3D ball ──────────────────────────── const r = bH * 0.44, bcy = bTop + r; drawSphere(cx, bcy, r, css.length ? css : [c1]); _st(ctx, cx, bcy - r, stTop); // Topmark: red sphere (safe water style) const tmR = sz * 0.09; drawSphere(cx, stTop - tmR, tmR, [c1]); } else if (isSpar) { // ── SPAR / ESPEQUE — tall thin cylinder ──────────────────── const sw = bW * 0.24, sparTop = bTop - bH * 0.40; drawCylinder(cx - sw/2, sparTop, sw, bH * 1.40, css.length ? css : [c1]); // Topmark: spar lateral = same rule por catlam if (cl === 1 || cl === 3) drawTopmarkCan (cx, sparTop - 2, sw * 1.4, c1); else if (cl === 2 || cl === 4) drawTopmarkCone(cx, sparTop - 2, sw * 1.4, c1); else drawTopmarkConeSmall(cx, sparTop - 2, sw * 1.4, c1); } else if (isBar) { // ── BARREL (Barril) — fat ellipsoid with stave lines ─────── const rx = bW * 0.52, ry = bH * 0.46, bcy = bTop + ry; // Body fill with sphere-style 3D drawSphere(cx, bcy, Math.min(rx, ry), css.length ? css : [c1]); // Barrel top cap ellipse const tg2 = ctx.createRadialGradient(cx - rx*0.3, bcy-ry, 2, cx, bcy-ry, rx); tg2.addColorStop(0, 'rgba(255,255,255,0.65)'); tg2.addColorStop(1, _lighten3D(c1, 0.2)); ctx.beginPath(); ctx.ellipse(cx, bcy - ry, rx * 0.55, ry * 0.16, 0, 0, Math.PI*2); ctx.fillStyle = tg2; ctx.fill(); outln(0.5); // Barrel bottom cap ellipse ctx.beginPath(); ctx.ellipse(cx, bcy + ry, rx * 0.55, ry * 0.16, 0, 0, Math.PI*2); ctx.fillStyle = _darken3D(c1, 0.35); ctx.fill(); outln(0.5); // Barrel stave ring lines (3 horizontal bands) [0.32, 0.50, 0.68].forEach(t => { ctx.beginPath(); ctx.ellipse(cx, bcy - ry + ry*2*t, rx * 0.52, ry * 0.08, 0, 0, Math.PI*2); ctx.strokeStyle = _darken3D(c1, 0.45); ctx.lineWidth = 0.7; ctx.stroke(); }); _st(ctx, cx, bcy - ry, stTop); if (cl === 1 || cl === 3) drawTopmarkCan (cx, stTop, bW * 0.40, c1); else if (cl === 2 || cl === 4) drawTopmarkCone(cx, stTop, bW * 0.42, c1); else drawTopmarkConeSmall(cx, stTop, bW * 0.44, c1); } else if (isSuper) { // ── SUPER-BUOY / LANBY — wide low platform + tower ───────── const fw = bW * 1.10, fh = bH * 0.55; const fy = bBot - fh; // Lower hull (flat cylinder) drawCylinder(cx - fw/2, fy, fw, fh, css.length ? css : [c1]); // Tower / mast on top const tw = bW * 0.28, th = bH * 0.60; drawCylinder(cx - tw/2, fy - th, tw, th, [_darken3D(c1, 0.10)]); // Light housing at top of tower (small sphere) drawSphere(cx, fy - th - sz*0.07, sz*0.07, [_lighten3D(c1, 0.3)]); // Staff tip _st(ctx, cx, fy - th - sz*0.14, stTop); } else { // ── PILLAR (BOYSHP=4 or unknown) — hull + superstructure ─── const pw = bW * 0.48; // Lower rounded hull (trapezoid wider at bottom) drawCylinder(cx - pw/2, bTop + bH*0.4, pw, bH*0.6, css.length ? css : [c1]); // Upper narrower superstructure const sw2 = pw * 0.7; drawCylinder(cx - sw2/2, bTop, sw2, bH*0.4, [_darken3D(c1, 0.08)]); _st(ctx, cx, bTop, stTop); // Topmark depends on lateral significance (catlam), NOT on hull shape. // PORT (babor, catlam 1/3) → cuadrado (can) // STBD (estribor, catlam 2/4) → cono (cone) if (cl === 1 || cl === 3) drawTopmarkCan (cx, stTop, bW * 0.42, c1); else if (cl === 2 || cl === 4) drawTopmarkCone(cx, stTop, bW * 0.44, c1); else drawTopmarkConeSmall(cx, stTop, bW * 0.50, c1); } // ── Waterline ────────────────────────────────────────────────────────── _wl(ctx, cx, wlY, bW * 0.68); // ── Light flare (S-52 magenta, when buoy has a light) ───────────────── if (opts.hasLight) { _drawLightFlare(ctx, cx, stTop + sz * 0.02, css[0] || '#ffffff'); } return c; } // ── Cardinal buoy (BOYCAR) — black/yellow banded pillar + two-cone topmark ── // N=▲▲ S=▼▼ E=▲▼(base-base) W=▼▲(point-point) // Body banding: N=Blk/Yel S=Yel/Blk E=Blk/Yel/Blk W=Yel/Blk/Yel function _encCardinalCanvas(quadrant, sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const BLK = '#111111', YEL = '#f9a825'; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.20; const bW = sz * 0.44, bH = bBot - bTop; // Body banding const bands = { N:['#111','#f9a825'], NE:['#111','#f9a825'], NW:['#111','#f9a825'], S:['#f9a825','#111'], SE:['#f9a825','#111'], SW:['#f9a825','#111'], E:['#111','#f9a825','#111'], W:['#f9a825','#111','#f9a825'], }[quadrant] || [BLK, YEL]; const bandH = bH / bands.length; bands.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(cx - bW/2, bTop + i * bandH, bW, bandH); }); ctx.strokeStyle = '#444'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); // Two-cone topmark const cw = sz * 0.36, ch = sz * 0.14; const t1 = sTop - ch * 0.2; // lower cone top y const t2 = t1 - ch - sz * 0.01; // upper cone top y if (quadrant === 'N' || quadrant === 'NE' || quadrant === 'NW') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'S' || quadrant === 'SE' || quadrant === 'SW') { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'E') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Isolated Danger (BOYISD) — black + red band + two black spheres ──────── function _encIsdCanvas(sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.28; const bW = sz * 0.44, bH = bBot - bTop; ctx.fillStyle = '#111'; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.fillStyle = '#e53935'; ctx.fillRect(cx - bW/2, bTop + bH * 0.35, bW, bH * 0.30); ctx.strokeStyle = '#444'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); const sr = sz * 0.07; _tmSphere(ctx, cx - sr * 1.2, sTop - sr, sr, '#111'); _tmSphere(ctx, cx + sr * 1.2, sTop - sr, sr, '#111'); _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Safe Water (BOYSAW) — red/white vertical stripes + red sphere ────────── function _encSawCanvas(sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.28; const bW = sz * 0.44, bH = bBot - bTop; // Vertical red/white stripes clipped to body rect ctx.save(); ctx.beginPath(); ctx.rect(cx - bW/2, bTop, bW, bH); ctx.clip(); const stripes = 4, sw = bW / stripes; for (let i = 0; i < stripes; i++) { ctx.fillStyle = i % 2 === 0 ? '#e53935' : '#ffffff'; ctx.fillRect(cx - bW/2 + i * sw, bTop, sw, bH); } ctx.restore(); ctx.strokeStyle = '#444'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); _tmSphere(ctx, cx, sTop - sz * 0.07, sz * 0.07, '#e53935'); _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Special Mark (BOYSPP) — yellow body + yellow X topmark ──────────────── function _encSppCanvas(sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.28; const bW = sz * 0.44, bH = bBot - bTop; ctx.fillStyle = '#f9a825'; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.strokeStyle = '#555'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); _tmX(ctx, cx, sTop - sz * 0.09, sz * 0.1, '#f9a825'); _wl(ctx, cx, wlY, bW * 0.65); return c; } /* ═══════════════════════════════════════════════════════════════════════════ IALA buoy canvases — exact port of AR ECDIS SVG defs (ui/js/map.js). Each function returns a canvas sized scale × SVG viewBox, with the position dot at the bottom anchor (white halo + black inner). anchor=[0.5, 1.0] in the OL Icon style places that dot on the geo point. ═══════════════════════════════════════════════════════════════════════════ */ const _IALA_S = 0.85; // canvas px per SVG viewBox unit (slightly smaller than native) function _ialaCanvas(svgW, svgH) { const c = document.createElement('canvas'); c.width = Math.round(svgW * _IALA_S); c.height = Math.round(svgH * _IALA_S); return c; } function _ialaPosDot(ctx, cx, cy) { const s = _IALA_S; ctx.beginPath(); ctx.arc(cx*s, cy*s, 3*s, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.beginPath(); ctx.arc(cx*s, cy*s, 1.5*s, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); } function _ialaRoundRect(ctx, x, y, w, h, r) { if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); } else { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } } // ── Lateral port — IALA-A: red can. IALA-B: green can. ──────────────────── function _ialaBoyLatPort(ialaB) { const c = _ialaCanvas(20, 30); const ctx = c.getContext('2d'); const s = _IALA_S; const top = ialaB ? '#33dd33' : '#ee2222'; const body = ialaB ? '#009900' : '#cc0000'; const bot = ialaB ? '#007700' : '#aa0000'; ctx.lineWidth = 1; ctx.strokeStyle = '#111'; // top ellipse ctx.beginPath(); ctx.ellipse(10*s, 7*s, 6*s, 2.5*s, 0, 0, Math.PI*2); ctx.fillStyle = top; ctx.fill(); ctx.stroke(); // body _ialaRoundRect(ctx, 4*s, 7*s, 12*s, 14*s, 2*s); ctx.fillStyle = body; ctx.fill(); ctx.stroke(); // bottom ellipse ctx.beginPath(); ctx.ellipse(10*s, 21*s, 6*s, 2*s, 0, 0, Math.PI*2); ctx.fillStyle = bot; ctx.fill(); ctx.stroke(); _ialaPosDot(ctx, 10, 27); return c; } // ── Lateral starboard — IALA-A: green cone. IALA-B: red cone. ───────────── function _ialaBoyLatStbd(ialaB) { const c = _ialaCanvas(20, 28); const ctx = c.getContext('2d'); const s = _IALA_S; const fill = ialaB ? '#cc0000' : '#009900'; ctx.beginPath(); ctx.moveTo(10*s, 2*s); ctx.lineTo(18*s, 21*s); ctx.lineTo(2*s, 21*s); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke(); _ialaPosDot(ctx, 10, 25); return c; } // ── Cardinal N/S/E/W — pillar + 2-cone topmark ──────────────────────────── function _ialaBoyCar(quadrant) { const isEW = quadrant === 'E' || quadrant === 'W'; const svgH = isEW ? 42 : 40; const c = _ialaCanvas(20, svgH); const ctx = c.getContext('2d'); const s = _IALA_S; const BLK = '#111', YEL = '#ffdd00'; ctx.lineWidth = 0.5*s; ctx.strokeStyle = BLK; const pole = (y1, y2) => { ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(10*s, y1*s); ctx.lineTo(10*s, y2*s); ctx.stroke(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5*s; }; const cone = (apex, baseY, baseLeft, baseRight, fill) => { ctx.beginPath(); ctx.moveTo(apex[0]*s, apex[1]*s); ctx.lineTo(baseRight[0]*s, baseRight[1]*s); ctx.lineTo(baseLeft[0]*s, baseLeft[1]*s); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.stroke(); }; if (quadrant === 'N') { cone([10,0], 7, [6,7], [14,7], BLK); cone([10,8], 15,[6,15], [14,15], BLK); pole(15, 19); ctx.fillStyle = BLK; ctx.fillRect(6*s, 19*s, 8*s, 6*s); ctx.fillStyle = YEL; ctx.fillRect(6*s, 25*s, 8*s, 6*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 19*s, 8*s, 12*s); _ialaPosDot(ctx, 10, 37); } else if (quadrant === 'S') { // 2 cones pointing down ctx.beginPath(); ctx.moveTo(6*s,0); ctx.lineTo(14*s,0); ctx.lineTo(10*s,7*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(6*s,8*s); ctx.lineTo(14*s,8*s); ctx.lineTo(10*s,15*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); pole(15, 19); ctx.fillStyle = YEL; ctx.fillRect(6*s, 19*s, 8*s, 6*s); ctx.fillStyle = BLK; ctx.fillRect(6*s, 25*s, 8*s, 6*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 19*s, 8*s, 12*s); _ialaPosDot(ctx, 10, 37); } else if (quadrant === 'E') { // diamond: cone up + cone down, bases joined ctx.beginPath(); ctx.moveTo(10*s,0); ctx.lineTo(16*s,8*s); ctx.lineTo(10*s,16*s); ctx.lineTo(4*s,8*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); pole(16, 20); ctx.fillStyle = BLK; ctx.fillRect(6*s, 20*s, 8*s, 5*s); ctx.fillStyle = YEL; ctx.fillRect(6*s, 25*s, 8*s, 5*s); ctx.fillStyle = BLK; ctx.fillRect(6*s, 30*s, 8*s, 5*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 20*s, 8*s, 15*s); _ialaPosDot(ctx, 10, 39); } else { // W // bowtie: cone down on top + cone up on bottom, points meet at y=8 ctx.beginPath(); ctx.moveTo(4*s,0); ctx.lineTo(16*s,0); ctx.lineTo(10*s,8*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(4*s,16*s); ctx.lineTo(16*s,16*s); ctx.lineTo(10*s,8*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); pole(16, 20); ctx.fillStyle = YEL; ctx.fillRect(6*s, 20*s, 8*s, 5*s); ctx.fillStyle = BLK; ctx.fillRect(6*s, 25*s, 8*s, 5*s); ctx.fillStyle = YEL; ctx.fillRect(6*s, 30*s, 8*s, 5*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 20*s, 8*s, 15*s); _ialaPosDot(ctx, 10, 39); } return c; } // ── Isolated Danger — BRB body + 2 black spheres on top ──────────────────── function _ialaBoyIsd() { const c = _ialaCanvas(22, 44); const ctx = c.getContext('2d'); const s = _IALA_S; const BLK = '#111', RED = '#cc0000'; ctx.fillStyle = BLK; ctx.beginPath(); ctx.arc(11*s, 4*s, 4*s, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(11*s, 12*s, 4*s, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(11*s, 16*s); ctx.lineTo(11*s, 20*s); ctx.stroke(); ctx.fillStyle = BLK; ctx.fillRect(5*s, 20*s, 12*s, 5*s); ctx.fillStyle = RED; ctx.fillRect(5*s, 25*s, 12*s, 5*s); ctx.fillStyle = BLK; ctx.fillRect(5*s, 30*s, 12*s, 5*s); _ialaPosDot(ctx, 11, 41); return c; } // ── Safe Water — RW horizontal bands + red sphere on top ─────────────────── function _ialaBoySaw() { const c = _ialaCanvas(18, 38); const ctx = c.getContext('2d'); const s = _IALA_S; const RED = '#cc0000'; ctx.fillStyle = RED; ctx.beginPath(); ctx.arc(9*s, 4*s, 4*s, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(9*s, 8*s); ctx.lineTo(9*s, 12*s); ctx.stroke(); ctx.fillStyle = RED; ctx.fillRect(3*s, 12*s, 12*s, 5*s); ctx.fillStyle = '#fff'; ctx.fillRect(3*s, 17*s, 12*s, 5*s); ctx.fillStyle = RED; ctx.fillRect(3*s, 22*s, 12*s, 5*s); ctx.fillStyle = '#fff'; ctx.fillRect(3*s, 27*s, 12*s, 3*s); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5*s; ctx.strokeRect(3*s, 12*s, 12*s, 18*s); _ialaPosDot(ctx, 9, 35); return c; } // ── Special Mark — yellow square + yellow X topmark ─────────────────────── function _ialaBoySpp() { const c = _ialaCanvas(20, 32); const ctx = c.getContext('2d'); const s = _IALA_S; const YEL_DARK = '#cc9900', YEL = '#ffcc00'; ctx.strokeStyle = YEL_DARK; ctx.lineWidth = 2.5*s; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(6*s, 1*s); ctx.lineTo(14*s, 9*s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(14*s, 1*s); ctx.lineTo(6*s, 9*s); ctx.stroke(); ctx.lineCap = 'butt'; ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(10*s, 9*s); ctx.lineTo(10*s, 13*s); ctx.stroke(); _ialaRoundRect(ctx, 4*s, 13*s, 12*s, 12*s, 2*s); ctx.fillStyle = YEL; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1*s; ctx.stroke(); _ialaPosDot(ctx, 10, 29); return c; } // ── White-light structure symbol (LIGHTS blanco / faro de orilla) ───────────── // Diseño: rectángulo cuerpo blanco con borde negro + estrella de 5 puntas centrada // + lagrima estirada de color purpura que sale del costado superior hacia arriba // a ~20° de la vertical (convención ECDIS / S-52 adaptada). // Para luces de color (rojo/verde) se usa el símbolo de cuerpo de boya en _encLightCanvas. function _ialaLight(colourCode) { const W = 26, H = 46; const c = document.createElement('canvas'); c.width = W; c.height = H; const ctx = c.getContext('2d'); const cx = W / 2; // Sólo se invoca para luces blancas (kind==='flare'); defensivamente mapeamos colores. let col = '#ffffff'; if (colourCode === 3) col = '#dd1111'; else if (colourCode === 4) col = '#00aa00'; else if (colourCode === 6) col = '#ffcc00'; // ── Cuerpo rectangulo (estructura del faro) ─────────────────────────────── const bW = 14, bH = 17; const bTop = H * 0.50; // tope del rectángulo const bBot = bTop + bH; // base del rectángulo ctx.beginPath(); ctx.rect(cx - bW / 2, bTop, bW, bH); ctx.fillStyle = col; // blanco (o color para casos defensivos) ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1.2; ctx.stroke(); // ── Estrella de 5 puntas centrada en el rectángulo ──────────────────────── const starX = cx, starY = bTop + bH / 2; const Ro = 4.5, Ri = 1.85; // radio exterior / interior ctx.beginPath(); for (let i = 0; i < 10; i++) { const ang = (i * Math.PI / 5) - Math.PI / 2; // empieza arriba const rr = (i % 2 === 0) ? Ro : Ri; const px = starX + Math.cos(ang) * rr; const py = starY + Math.sin(ang) * rr; i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); } ctx.closePath(); ctx.fillStyle = (col === '#ffffff') ? '#222' : '#fff'; // contraste ctx.fill(); // ── Lagrima estirada purpura (haz de luz) ───────────────────────────────── // Sale del costado superior-derecho del rectángulo, ~20° de la vertical. const PURP = '#8800cc'; const tearAng = 20 * Math.PI / 180; // 20° desde la vertical const tearLen = 18; // longitud de la lagrima (px) ctx.save(); // Ancla en el extremo superior-derecho del rectángulo ctx.translate(cx + bW / 2 - 1, bTop + 1); ctx.rotate(tearAng); // rotar 20° hacia la derecha // Forma de lagrima: estrecha en la base, ancha a la mitad, punta arriba ctx.beginPath(); ctx.moveTo(0, 0); // base (anclaje) ctx.bezierCurveTo(-3.8, -5, -3.8, -11, 0, -tearLen);// lado izquierdo ctx.bezierCurveTo( 3.8, -11, 3.8, -5, 0, 0); // lado derecho ctx.closePath(); ctx.fillStyle = PURP; ctx.globalAlpha = 0.88; ctx.fill(); ctx.globalAlpha = 1.0; ctx.strokeStyle = 'rgba(90,0,140,0.5)'; ctx.lineWidth = 0.5; ctx.stroke(); ctx.restore(); // ── Punto de anclaje geográfico en la base ──────────────────────────────── ctx.beginPath(); ctx.arc(cx, H - 2, 2.2, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.beginPath(); ctx.arc(cx, H - 2, 1.0, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); return c; } // IHO S-52 "magenta flare" — stylised flash extending up-right from the // light's position. The flare is always magenta (chart convention for "this // is a light symbol"); the actual colour of the emitted light is shown as a // small coloured dot at the apex of the flare. function _drawLightFlare(ctx, cx, y, col) { const MAG = '#ff00aa'; // S-52 magenta const len = 8; // flare length (px) ctx.save(); ctx.translate(cx, y); // Flare: thin triangular wedge at 45° up-right ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(len * 0.95, -len * 0.30); ctx.lineTo(len * 0.30, -len * 0.95); ctx.closePath(); ctx.fillStyle = MAG; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.5; ctx.stroke(); // Position dot at the light's geographic point ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fillStyle = MAG; ctx.fill(); // Coloured tip: shows the actual emitted-light colour if (col && col !== MAG) { ctx.beginPath(); ctx.arc(len * 0.62, -len * 0.62, 1.8, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.4; ctx.stroke(); } ctx.restore(); } // ── Lighted aid (LIGHTS feature) — infer lateral/special from colour + region ─ // S-57 LIGHTS objects often appear without a paired BOYLAT structure. We infer // the host's shape from the light's COLOUR + the cell's IALA region: // IALA-B: green light → port can ; red light → stbd nun // IALA-A: green light → stbd nun ; red light → port can // yellow → SPECIAL ; white/magenta/null → simple flare (no shape inferred) function _encLightCanvas(colours, region, sz = 32) { const raw = colours[0]; const ialaB = (region || 'B') === 'B'; // Decide implied lateral significance from the light's colour let kind = 'flare'; // default if (raw === 4) kind = ialaB ? 'can' : 'cone'; // green else if (raw === 3) kind = ialaB ? 'cone' : 'can'; // red else if (raw === 6) kind = 'special'; // yellow // Pure light feature — render the S-52 radial-cross symbol (ECDIS style). if (kind === 'flare') { return _ialaLight(raw); } // Otherwise the light is hosted on a buoy shape (can/cone/special). const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const col = (!raw || raw === 1) ? '#cc00cc' : _s57css(raw); // Draw a small body silhouette with the light dot perched on its top. const bBot = sz * 0.86, bTop = sz * 0.50, bW = sz * 0.42, bH = bBot - bTop; if (kind === 'can') { ctx.fillStyle = col; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); } else if (kind === 'cone') { ctx.beginPath(); ctx.moveTo(cx, bTop); ctx.lineTo(cx + bW/2, bBot); ctx.lineTo(cx - bW/2, bBot); ctx.closePath(); ctx.fillStyle = col; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.stroke(); } else if (kind === 'special') { // Yellow body, X topmark style — borrowed from BOYSPP look ctx.fillStyle = '#f9a825'; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.strokeStyle = '#555'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _tmX(ctx, cx, bTop - sz * 0.06, sz * 0.08, '#f9a825'); } // Waterline _wl(ctx, cx, sz * 0.90, bW * 0.7); // Light flare sits at the top of the body _drawLightFlare(ctx, cx, bTop - sz * 0.10, col); return c; } // ── Lateral beacon (BCNLAT) — poste + cuerpo + HUECO + marca de tope ───────── // Estructura real IALA: palo delgado continuo + cuerpo rectangular en la parte // inferior + tramo de palo libre (hueco visible) + marca de tope separada arriba. // Babor (port, IALA-B verde): marca de tope = cuadrado. // Estribor (stbd, IALA-B rojo): marca de tope = triángulo apuntando arriba. // La marca de tope es levemente más ancha que el cuerpo y queda separada de él. function _encBeaconCanvas(colours, catlam, region, sz = 36) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; let cc = (colours || []).slice(); if (!cc.length && catlam) cc = _ialaLateralColours(catlam, region); const col = cc[0] ? _s57css(cc[0]) : '#78909c'; const isPort = catlam === 1 || catlam === 3; // IALA-B: babor=verde, estribor=rojo // ── Layout: poste continuo + cuerpo (bajo) + hueco + marca de tope (arriba) ─ const groundY = sz * 0.92; // base / nivel del suelo const poleTopY = sz * 0.04; // cima del poste const poleW = sz * 0.055; // grosor del poste delgado // Cuerpo (shaft): rectángulo en la mitad inferior del poste const bodyW = sz * 0.28; const bodyTopY = sz * 0.48; // tope del cuerpo → aquí empieza el hueco visible const bodyBotY = groundY; // Marca de tope: separada del cuerpo, levemente más ancha const tmW = sz * 0.36; // ancho de la marca de tope const tmBotY = sz * 0.27; // base de la marca (límite superior del hueco) const tmTopY = sz * 0.05; // cima de la marca (= ≈ tope del poste) const tmH = tmBotY - tmTopY; // ── Sombra elíptica en la base ──────────────────────────────────────────── ctx.beginPath(); ctx.ellipse(cx, groundY, sz * 0.12, sz * 0.020, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill(); // ── Poste continuo (todo el alto, dibujado primero = detrás) ───────────── const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0); poleGrd.addColorStop(0, '#999'); poleGrd.addColorStop(0.4, '#ddd'); poleGrd.addColorStop(1, '#777'); ctx.beginPath(); ctx.rect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY); ctx.fillStyle = poleGrd; ctx.fill(); // ── Gradiente de color IALA (compartido por cuerpo y marca de tope) ─────── const mkGrd = (w) => { const g = ctx.createLinearGradient(cx - w / 2, 0, cx + w / 2, 0); g.addColorStop(0, _lighten3D(col, 0.35)); g.addColorStop(0.45, col); g.addColorStop(1, _darken3D(col, 0.28)); return g; }; ctx.strokeStyle = 'rgba(0,0,0,0.65)'; ctx.lineWidth = 0.9; // ── Cuerpo (shaft rectangulo, parte inferior) ───────────────────────────── ctx.beginPath(); ctx.rect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY); ctx.fillStyle = mkGrd(bodyW); ctx.fill(); ctx.stroke(); // Highlight lateral izquierdo ctx.beginPath(); ctx.rect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fill(); // ── Marca de tope (separada del cuerpo por el hueco = tramo de poste libre) ─ if (isPort) { // Babor: cuadrado (square daymark) ctx.beginPath(); ctx.rect(cx - tmW / 2, tmTopY, tmW, tmH); ctx.fillStyle = mkGrd(tmW); ctx.fill(); ctx.stroke(); // Highlight lateral ctx.beginPath(); ctx.rect(cx - tmW / 2, tmTopY, tmW * 0.20, tmH); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fill(); } else { // Estribor: triángulo apuntando hacia arriba (triangle daymark) ctx.beginPath(); ctx.moveTo(cx, tmTopY); // vértice superior (apex) ctx.lineTo(cx + tmW / 2, tmBotY); // esquina inferior derecha ctx.lineTo(cx - tmW / 2, tmBotY); // esquina inferior izquierda ctx.closePath(); ctx.fillStyle = mkGrd(tmW); ctx.fill(); ctx.stroke(); } return c; } // ── Range / Leading-mark dayboard — dos triángulos concéntricos ────────────── // Símbolo IALA: triángulo exterior BLANCO + interior NEGRO con lados // estrictamente paralelos (triángulo similar centrado en el baricentro). // Tamaño reducido para no dominar el mapa. function _encRangeDaymarkCanvas(colours, hasLight, sz = 34) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; // Light colour for optional flare const lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff'; // ── Layout ─────────────────────────────────────────────────────────────── const triTop = sz * 0.06; // apex of outer triangle const triBot = sz * 0.94; // base of outer triangle const halfW = sz * 0.44; // half-width at base // ── OUTER triangle — WHITE ─────────────────────────────────────────────── ctx.beginPath(); ctx.moveTo(cx, triTop); ctx.lineTo(cx + halfW, triBot); ctx.lineTo(cx - halfW, triBot); ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.0; ctx.stroke(); // ── INNER triangle — BLACK, lados paralelos al exterior ────────────────── // Escalar desde el baricentro garantiza lados paralelos (triángulo similar). const r = 0.48; // escala del interior const cy = (triTop + 2 * triBot) / 3; // baricentro Y del triángulo exterior const iApexY = cy + r * (triTop - cy); // vértice superior del interior const iBaseY = cy + r * (triBot - cy); // base del interior const iHalf = halfW * r; // media-anchura → lados paralelos ctx.beginPath(); ctx.moveTo(cx, iApexY); ctx.lineTo(cx + iHalf, iBaseY); ctx.lineTo(cx - iHalf, iBaseY); ctx.closePath(); ctx.fillStyle = '#111111'; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 0.6; ctx.stroke(); // ── Light flare al ápice (si la feature tiene luz) ─────────────────────── if (hasLight) { _drawLightFlare(ctx, cx, triTop - sz * 0.02, lightCss); } return c; } // ── Cardinal beacon (BCNCAR) — tripod + two-cone topmark ───────────────────── function _encBcnCardinalCanvas(quadrant, sz = 30) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const BLK = '#111'; const baseY = sz * 0.90, poleTop = sz * 0.30; ctx.strokeStyle = '#444'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx - sz*0.22, baseY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx + sz*0.22, baseY); ctx.stroke(); ctx.strokeStyle = '#444'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cx, baseY); ctx.lineTo(cx, poleTop); ctx.stroke(); const cw = sz * 0.42, ch = sz * 0.16; const t1 = poleTop - sz * 0.02; const t2 = t1 - ch - sz * 0.01; if (quadrant === 'N') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'S') { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'E') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } return c; } /* ═══════════════════════════════════════════════════════════════════════════ Multi-cell composition (port from AR ECDIS). - usage_band derived from the 3rd char of the cell id (IHO/NOAA convention): US1GC09M → 1 Overview, US5MIA* → 5 Harbour, etc. - Each band is rendered only inside its own zoom range. Cells with band 0 (unknown) are always rendered. - When a higher-band cell covers ≥40% of the viewport, lower-band cells are hidden — so the harbour cell takes over while the overview stays loaded for panning to less detailed areas. ═══════════════════════════════════════════════════════════════════════════ */ window._chartCells = {}; // cell_id → { bbox: [w,s,e,n], band: int } const _HIDDEN_CELLS = new Set(); // mutated on moveend by _recomputeHiddenCells // Band zoom ranges: [minZoom, maxZoom]. // Band 0 (custom/BARRANQUILLA) siempre visible [0,24]. // Bands 1-4 tienen maxZoom para que celdas overview no compitan con harbour // en zoom alto — sin estos límites, US1GC09M (549 features Overview) aparece // en zoom 11 sobre Miami y su declutter oculta las boyas de mayor resolución. // BARRANQUILLA no se afecta — su cell_id da band=0 por _bandFromCellId. const BAND_ZOOM_RANGES = { 0: [0, 24], // custom / unknown (ej. BARRANQUILLA) → siempre visible 1: [0, 10], // Overview (1:600 000+) — se oculta a zoom >10 2: [3, 12], // General (1:350 000) 3: [6, 15], // Coastal (1:90 000) 4: [8, 18], // Approach (1:25 000) 5: [10, 24], // Harbour (1:7 500) 6: [12, 24], // Berthing (1:2 000) }; function _bandFromCellId(id) { const m = (id || '').match(/^[A-Za-z]{2}([1-6])/); return m ? +m[1] : 0; } // Returns false if this feature's cell should NOT render at the current resolution. function _cellShouldRender(cellId, resolution) { if (!cellId) return true; if (_HIDDEN_CELLS.has(cellId)) return false; const meta = window._chartCells[cellId]; const band = meta ? meta.band : _bandFromCellId(cellId); const zoom = map.getView().getZoomForResolution(resolution); const [zmin, zmax] = BAND_ZOOM_RANGES[band] || BAND_ZOOM_RANGES[0]; return zoom >= zmin && zoom <= zmax; } // On moveend, hide overview cells whose viewport overlap is dominated by a // higher-band cell (≥40% of the viewport area covered by the higher one). // IMPORTANT: only count a higher-band cell as "dominating" if that cell is // actually within its own zoom range at the current zoom. Otherwise band-4 // cells get hidden by a band-5 cell that is itself invisible (zoom < 10), // leaving a gap where NO chart renders at all (the Miami disappear bug). function _recomputeHiddenCells() { _HIDDEN_CELLS.clear(); const cells = window._chartCells; if (!cells || Object.keys(cells).length === 0) return; const ext = map.getView().calculateExtent(map.getSize()); const zoom = map.getView().getZoom() || 0; const [w, s, e, n] = ol.proj.transformExtent(ext, 'EPSG:3857', 'EPSG:4326'); const vArea = (e - w) * (n - s); if (vArea <= 0) return; for (const [cellId, meta] of Object.entries(cells)) { if (!meta.bbox) continue; for (const [otherId, other] of Object.entries(cells)) { if (otherId === cellId || !other.bbox) continue; if (other.band <= meta.band) continue; // Only treat 'other' as dominating if it is actually visible at current zoom. // Without this check, a harbour cell (band-5, minZoom=10) hides the approach // cell (band-4) even at zoom 9.x, where band-5 is itself out of range → nothing renders. const [ozMin, ozMax] = BAND_ZOOM_RANGES[other.band] || BAND_ZOOM_RANGES[0]; if (zoom < ozMin || zoom > ozMax) continue; const iw = Math.max(0, Math.min(e, other.bbox[2]) - Math.max(w, other.bbox[0])); const ih = Math.max(0, Math.min(n, other.bbox[3]) - Math.max(s, other.bbox[1])); if ((iw * ih) / vArea >= 0.40) { _HIDDEN_CELLS.add(cellId); break; } } } } async function _loadChartCells() { try { const r = await fetch('/charts/cells'); const cells = await r.json(); cells.forEach(c => { window._chartCells[c.id] = { bbox: c.bbox, band: _bandFromCellId(c.id), }; }); _recomputeHiddenCells(); [encSource, depthSource, landSource, zoneSource, hazardSource, soundSource] .forEach(src => src.changed()); } catch (e) { console.warn('[charts] failed to load cell catalog:', e); } } // ── Leading line (enfilación) style ────────────────────────────────────── // S-52: leading line = dashed orange/magenta line with arrowhead at the far // end showing the bearing direction. The line is always rendered (no zoom // threshold) so navigators can see it at any scale. function _ldlineStyle(feature) { const orient = feature.get('orient'); // True bearing, degrees const colours = feature.get('colours') || []; // Colour: use the light's own colour when available, else default magenta const col = colours[0] === 4 ? '#00cc44' // green light → green line : colours[0] === 3 ? '#ff4444' // red light → red line : '#ff8800'; // default orange (leading line) const lineStyle = new ol.style.Style({ stroke: new ol.style.Stroke({ color: col, width: 1.6, lineDash: [8, 5], }), }); const styles = [lineStyle]; // Arrowhead at the far end of the line (bearing direction) if (orient != null) { const coords = feature.getGeometry().getCoordinates(); const end = coords[coords.length - 1]; const start = coords[coords.length - 2]; // Angle of the line in screen space (OL is EPSG:3857) const dx = end[0] - start[0], dy = end[1] - start[1]; const angle = Math.atan2(dy, dx); // radians, CCW from east // Small arrowhead canvas const asz = 14; const ac = document.createElement('canvas'); ac.width = asz; ac.height = asz; const ax = ac.getContext('2d'); ax.strokeStyle = col; ax.lineWidth = 2; ax.lineCap = 'round'; ax.translate(asz / 2, asz / 2); ax.rotate(-angle); // align with line direction ax.beginPath(); ax.moveTo(-5, -4); ax.lineTo(0, 0); ax.lineTo(-5, 4); ax.stroke(); styles.push(new ol.style.Style({ geometry: new ol.geom.Point(end), image: new ol.style.Icon({ img: ac, imgSize: [asz, asz], rotation: -angle, // OL rotation is CW rotateWithView: true, }), })); } // Bearing label — always visible, centred on the line. // Format: "143.5° E12 — E16" (bearing + paired mark names) const name = feature.get('name') || ''; const bearTxt = orient != null ? `${orient % 1 === 0 ? orient : orient.toFixed(1)}°` : ''; const lbl = [bearTxt, name].filter(Boolean).join(' '); if (lbl) { const coords = feature.getGeometry().getCoordinates(); // Place label at 1/3 from start (near the front mark) so it's in the channel const idx = Math.max(0, Math.floor(coords.length / 3)); const mid = coords[idx]; styles.push(new ol.style.Style({ geometry: new ol.geom.Point(mid), text: new ol.style.Text({ text: lbl, font: 'bold 10px "Inter", "Segoe UI", sans-serif', fill: new ol.style.Fill({ color: '#fff' }), stroke: new ol.style.Stroke({ color: 'rgba(0,0,0,0.50)', width: 2 }), offsetY: -12, overflow: true, }), })); } return styles; } // ── ENC (S-57 chart) style — dispatch by canonical aid_type ────────────── // resolution is in m/pixel (OL passes it). Labels only show when zoomed in. const ENC_LABEL_RES = 2.5; // ≈ zoom 16; closer than that → show name function encStyle(feature, resolution) { if (!_cellShouldRender(feature.get('cell'), resolution)) return null; const layer = (feature.get('layer') || '').toUpperCase(); const aidType = feature.get('aid_type') || 'UNKNOWN'; const showName = resolution != null && resolution < ENC_LABEL_RES; const name = showName ? (feature.get('name') || feature.get('nombre') || '') : ''; const colours = feature.get('colours') || []; const boyshp = feature.get('boyshp'); const catlam = feature.get('catlam'); const region = feature.get('cell_region') || window.IALA_REGION || 'B'; const isBcn = layer.startsWith('BCN'); const hasLight = !!(feature.get('light_desc')); const lightDesc = showName ? (feature.get('light_desc') || '') : ''; // Compose on-map label: name on first line, light character on second const labelParts = [name, lightDesc].filter(Boolean); const label = labelParts.join('\n'); const ialaB = (region || 'B') === 'B'; const hasOrient = feature.get('orient') != null; // feature defines a leading line bearing let canvas; // ── Range / Leading mark — dayboard symbol when ORIENT is present ──────── // Only for LIGHTS and LNDMRK features (not buoys/beacons that may have // inherited orient from a nearby light via the proximity merge). const isRangeMark = hasOrient && aidType !== 'LEADING_LINE' && !layer.startsWith('BOY') && !layer.startsWith('BCN'); if (isRangeMark) { canvas = _cachedIcon(`rangemark_${colours[0]}_${hasLight}`, () => _encRangeDaymarkCanvas(colours, hasLight)); // Switch is skipped below (canvas already set) } // Helper: add light flare to one of the fixed IALA canvas symbols const _withFlare = (baseFn, flareY, lightCss) => { const base = baseFn(); if (!hasLight) return base; const ct = base.getContext('2d'); _drawLightFlare(ct, base.width / 2, flareY, lightCss || '#ffffff'); return base; }; const _lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff'; // Dispatch by aid_type (skipped when isRangeMark already set canvas above). if (!canvas) switch (aidType) { // ── Lateral buoys — route through _encBuoyCanvas (BOYSHP-aware, 3-D) ── case 'LATERAL_PORT': case 'LATERAL_PREF_STBD': case 'LATERAL_UNKNOWN': { if (isBcn) { canvas = _cachedIcon(`bcn_port_${colours.join('-')}_${region}`, () => _encBeaconCanvas(colours, catlam, region)); } else { const shp = boyshp || 2; // default: can for port canvas = _cachedIcon(`boy_port_${colours.join('-')}_${shp}_${region}_${hasLight}`, () => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight })); } break; } case 'LATERAL_STBD': case 'LATERAL_PREF_PORT': { if (isBcn) { canvas = _cachedIcon(`bcn_stbd_${colours.join('-')}_${region}`, () => _encBeaconCanvas(colours, catlam, region)); } else { const shp = boyshp || 1; // default: conical for starboard canvas = _cachedIcon(`boy_stbd_${colours.join('-')}_${shp}_${region}_${hasLight}`, () => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight })); } break; } // ── Cardinal buoys ────────────────────────────────────────────────────── case 'CARDINAL_N': case 'CARDINAL_E': case 'CARDINAL_S': case 'CARDINAL_W': { const q = aidType.split('_')[1]; canvas = _cachedIcon(`iala_car_${q}_${hasLight}`, () => _withFlare(() => _ialaBoyCar(q), 2 * _IALA_S, _lightCss)); break; } case 'CARDINAL_UNKNOWN': canvas = _cachedIcon(`iala_car_unk_${hasLight}`, () => _withFlare(() => _ialaBoyCar('N'), 2 * _IALA_S, _lightCss)); break; // ── Other buoy types ──────────────────────────────────────────────────── case 'ISOLATED_DANGER': canvas = _cachedIcon(`iala_isd_${hasLight}`, () => _withFlare(() => _ialaBoyIsd(), 2 * _IALA_S, '#cc0000')); break; case 'SAFE_WATER': canvas = _cachedIcon(`iala_saw_${hasLight}`, () => _withFlare(() => _ialaBoySaw(), 2 * _IALA_S, '#cc0000')); break; case 'SPECIAL': { // BOYSPEC/BOYSPP — route through _encBuoyCanvas with sphere or special shape const shp = boyshp || 3; canvas = _cachedIcon(`boy_spp_${colours.join('-')}_${shp}_${hasLight}`, () => _encBuoyCanvas({ colours: colours.length ? colours : [6], boyshp: shp, catlam: null, region, hasLight })); break; } case 'LEADING_LINE': // Leading line — no canvas icon needed; pure line style handled below return _ldlineStyle(feature); case 'LIGHT_POINT': canvas = _cachedIcon(`enc_light_${colours[0]}_${region}`, () => _encLightCanvas(colours, region)); break; // ── Generic / fallback ────────────────────────────────────────────────── case 'LANDMARK': case 'RACON': case 'BEACON_GENERIC': { canvas = _cachedIcon(`bcn_gen_${colours.join('-')}_${region}_${hasLight}`, () => _encBeaconCanvas(colours, catlam, region)); break; } case 'BUOY_GENERIC': default: { // Always route through _encBuoyCanvas so every buoy gets 3-D symbology. // boyshp defaults to 4 (pillar) when unknown — visible & recognisable. const shp = boyshp || 4; canvas = _cachedIcon(`boy_gen_${colours.join('-')}_${shp}_${hasLight}`, () => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight })); } } // Safety net: if any draw function threw, canvas may be null. Fall back to // a small colored circle so the feature is still visible and clickable. if (!canvas) { const fc = document.createElement('canvas'); fc.width = fc.height = 16; const fx = fc.getContext('2d'); fx.beginPath(); fx.arc(8, 8, 7, 0, Math.PI * 2); fx.fillStyle = colours[0] ? _s57css(colours[0]) : '#607d8b'; fx.fill(); fx.strokeStyle = '#fff'; fx.lineWidth = 1.5; fx.stroke(); canvas = fc; console.warn('[ENC] canvas fallback for', layer, aidType, colours); } // Compute zoom from resolution (deterministic, no map object access needed). // OL WebMercator: resolution ≈ 156543 / 2^zoom → zoom = log2(156543/res) const _zr = resolution ? Math.log2(156543.03392 / Math.max(resolution, 0.001)) : 14; // Scale: 0.29 at zoom 7, 0.65 at zoom 14+ — ~30 % larger than before. const iconScale = Math.max(0.29, Math.min(0.65, 0.29 + (_zr - 7) * 0.052)); return new ol.style.Style({ image: new ol.style.Icon({ img: canvas, imgSize: [canvas.width, canvas.height], scale: iconScale, anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction', }), text: label ? new ol.style.Text({ text: label, offsetY: 10, font: '600 10px "Inter", "Segoe UI", sans-serif', fill: new ol.style.Fill({ color: '#e8f4fd' }), stroke: new ol.style.Stroke({ color: 'rgba(0,0,0,0.55)', width: 2 }), overflow: true, }) : undefined, }); } // ── Bathymetry rendering — IHO S-52 / NOAA convention ──────────────────── // Deeper water = paler / nearly white. Shallower = darker blue. Very shallow // (<2 m, "drying heights") gets a warm tint to scream "danger". function _depthFill(d) { // Tones taken from IHO INT 1 / NOAA paper charts: a soft "blue tint" // that gets paler with depth and turns to white below the safety contour. if (d == null) return 'rgba(216,234,244,0.85)'; // unknown if (d <= 1) return 'rgba(176,216,232,0.85)'; // ≤ 1 m NOAA shallow tint if (d < 2) return 'rgba(196,226,238,0.85)'; // 1 – 2 m if (d < 4) return 'rgba(216,234,244,0.85)'; // 2 – 4 m if (d < 10) return 'rgba(232,242,248,0.85)'; // 4 – 10 m return 'rgba(248,252,254,0.80)'; // ≥ 10 m near white } const _depthStyleCache = new Map(); function _depareStyle(depthMin) { const key = `dp_${depthMin}`; if (!_depthStyleCache.has(key)) { _depthStyleCache.set(key, new ol.style.Style({ fill: new ol.style.Fill({ color: _depthFill(depthMin) }), stroke: new ol.style.Stroke({ color: 'rgba(80,140,190,0.35)', width: 0.5 }), })); } return _depthStyleCache.get(key); } const _contourLine = new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'rgba(40,80,130,0.55)', width: 0.7 }), }); function _contourStyle(depth, resolution) { // Depth label every ~600 px along the line, only at zoom ≥ 14 (res < 10 m/px) if (depth == null || resolution > 10) return _contourLine; return [ _contourLine, new ol.style.Style({ text: new ol.style.Text({ text: depth.toString(), font: '500 9px "Inter", sans-serif', fill: new ol.style.Fill({ color: '#1a3a5c' }), stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.85)', width: 2 }), placement: 'line', textBaseline: 'middle', }), }), ]; } function _soundingStyle(depth, resolution) { // Numeric soundings — visible at most working zoom levels. if (depth == null || resolution > 30) return null; return new ol.style.Style({ text: new ol.style.Text({ text: depth.toString(), font: '500 10px "Inter", sans-serif', fill: new ol.style.Fill({ color: '#1a3a5c' }), stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 2.5 }), }), }); } // Solid-buff land style — paper-chart yellow. Only rendered in CHART mode // so the regular DEPTHS toggle doesn't accidentally cover the OSM basemap. let _chartModeActive = false; const _landMaskStyle = new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(248,231,184,1)' }), // NOAA paper buff stroke: new ol.style.Stroke({ color: 'rgba(140,115,70,0.7)', width: 0.6 }), }); function depthStyle(feature, resolution) { if (!_cellShouldRender(feature.get('cell'), resolution)) return null; const layer = feature.get('layer'); // DEPCNT contour lines always rendered — they're thin and don't cover OSM. // DEPARE polygons and LANDMASK fill paint opaque areas → restricted to // CHART mode where OSM is hidden. if (layer === 'DEPCNT') return _contourStyle(feature.get('depth'), resolution); if (layer === 'DEPARE') return _chartModeActive ? _depareStyle(feature.get('depth_min')) : null; if (layer === 'LANDMASK') return _chartModeActive ? _landMaskStyle : null; return null; } // SOUNDG soundings — rendered on top of land/buildings/aids (zIndex 8). function soundStyle(feature, resolution) { if (!_cellShouldRender(feature.get('cell'), resolution)) return null; return _soundingStyle(feature.get('depth'), resolution); } // ── Land / terrain styles ───────────────────────────────────────────────────── const _LNDARE_STYLE = new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(245,231,174,0.97)' }), stroke: new ol.style.Stroke({ color: 'rgba(140,115,70,0.5)', width: 0.5 }), }); const _BUAARE_STYLE = new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(215,200,160,0.97)' }), stroke: new ol.style.Stroke({ color: 'rgba(120,100,60,0.5)', width: 0.5 }), }); const _COALNE_STYLE = new ol.style.Style({ stroke: new ol.style.Stroke({ color: '#5a3e2b', width: 1.5 }), }); // Buildings — S-52 LANDA tone with visible outline; name shown when zoomed in function _buisglStyle(feature, resolution) { const name = resolution < 3 ? (feature.get('name') || '') : ''; return new ol.style.Style({ fill: new ol.style.Fill({ color: 'rgba(188,160,110,0.92)' }), stroke: new ol.style.Stroke({ color: 'rgba(100,75,30,0.9)', width: 1.2 }), text: name ? new ol.style.Text({ text: name, font: '500 9px "Inter", sans-serif', fill: new ol.style.Fill({ color: '#3e2a06' }), stroke: new ol.style.Stroke({ color: 'rgba(255,240,200,0.85)', width: 2 }), overflow: true, }) : undefined, }); } function _landPointCanvas(key, drawFn) { return _cachedIcon(key, drawFn); } function landStyle(feature, resolution) { if (!_cellShouldRender(feature.get('cell'), resolution)) return null; const layer = feature.get('layer'); if (layer === 'LNDARE') return _LNDARE_STYLE; if (layer === 'BUAARE') return _BUAARE_STYLE; if (layer === 'COALNE') return _COALNE_STYLE; if (layer === 'BUISGL') return _buisglStyle(feature, resolution); if (layer === 'BRIDGE') { const c = _landPointCanvas('lnd_bridge', () => { const cv = document.createElement('canvas'); cv.width = 16; cv.height = 10; const x = cv.getContext('2d'); x.fillStyle = '#555'; x.fillRect(0, 3, 16, 4); x.strokeStyle = '#333'; x.lineWidth = 1; x.strokeRect(0, 3, 16, 4); return cv; }); return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [c.width, c.height] }) }); } if (layer === 'HRBFAC') { const c = _landPointCanvas('lnd_harbour', () => { const cv = document.createElement('canvas'); cv.width = cv.height = 16; const x = cv.getContext('2d'); x.strokeStyle = '#1a5276'; x.lineWidth = 1.5; x.beginPath(); x.arc(8, 5, 3, 0, Math.PI * 2); x.stroke(); x.beginPath(); x.moveTo(8, 8); x.lineTo(8, 15); x.stroke(); x.beginPath(); x.moveTo(3, 12); x.lineTo(13, 12); x.stroke(); return cv; }); return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [16, 16] }) }); } if (layer === 'BUISGL' || layer === 'SILTNK') { const c = _landPointCanvas(`lnd_${layer}`, () => { const cv = document.createElement('canvas'); cv.width = cv.height = 10; const x = cv.getContext('2d'); x.fillStyle = '#aaa'; x.fillRect(1, 1, 8, 8); x.strokeStyle = '#666'; x.lineWidth = 1; x.strokeRect(1, 1, 8, 8); return cv; }); return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [10, 10] }) }); } return null; } // ── Hazard styles (IHO S-52 approximation) ──────────────────────────────────── const _WRECK_CANVAS = (() => { const c = document.createElement('canvas'); c.width = c.height = 18; const x = c.getContext('2d'); x.strokeStyle = '#7b0000'; x.lineWidth = 2; x.beginPath(); x.moveTo(3, 3); x.lineTo(15, 15); x.stroke(); x.beginPath(); x.moveTo(15, 3); x.lineTo(3, 15); x.stroke(); x.beginPath(); x.arc(9, 9, 4, 0, Math.PI * 2); x.strokeStyle = '#7b0000'; x.lineWidth = 1.5; x.stroke(); return c; })(); const _OBSTRN_CANVAS = (() => { const c = document.createElement('canvas'); c.width = c.height = 16; const x = c.getContext('2d'); x.strokeStyle = 'rgb(140,0,140)'; x.lineWidth = 1.5; x.beginPath(); x.arc(8, 8, 7, 0, Math.PI * 2); x.stroke(); x.fillStyle = 'rgb(140,0,140)'; x.beginPath(); x.arc(8, 8, 2.5, 0, Math.PI * 2); x.fill(); return c; })(); const _UWTROC_CANVAS = (() => { const c = document.createElement('canvas'); c.width = c.height = 16; const x = c.getContext('2d'); x.beginPath(); x.moveTo(8, 1); x.lineTo(15, 8); x.lineTo(8, 15); x.lineTo(1, 8); x.closePath(); x.fillStyle = 'rgba(0,90,170,0.75)'; x.fill(); x.strokeStyle = '#003366'; x.lineWidth = 1; x.stroke(); return c; })(); const _WRECK_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _WRECK_CANVAS, imgSize: [18, 18] }) }); const _OBSTRN_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _OBSTRN_CANVAS, imgSize: [16, 16] }) }); const _UWTROC_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _UWTROC_CANVAS, imgSize: [16, 16] }) }); function hazardStyle(feature, resolution) { if (!_cellShouldRender(feature.get('cell'), resolution)) return null; const layer = feature.get('layer'); if (layer === 'WRECKS') return _WRECK_STYLE; if (layer === 'OBSTRN') return _OBSTRN_STYLE; if (layer === 'UWTROC') return _UWTROC_STYLE; return null; } // ── Zone / area styles ──────────────────────────────────────────────────────── const _ZONE_CFG = { RESARE: { fill: 'rgba(200,0,0,0.07)', stroke: '#cc0000', dash: [6,4], w: 1.5 }, CTNARE: { fill: 'rgba(210,160,0,0.09)', stroke: '#b8860a', dash: [6,4], w: 1.5 }, ACHARE: { fill: 'rgba(0,70,180,0.07)', stroke: '#0044bb', dash: [5,5], w: 1.5 }, TSSLPT: { fill: 'rgba(0,130,0,0.07)', stroke: '#006600', dash: null, w: 1 }, FAIRWY: { fill: 'rgba(0,110,120,0.07)', stroke: '#005555', dash: [4,6], w: 1 }, PRCARE: { fill: 'rgba(190,90,0,0.07)', stroke: '#aa5500', dash: [6,4], w: 1.5 }, DMPGRD: { fill: 'rgba(100,100,100,0.07)', stroke: '#555555', dash: [3,5], w: 1 }, PIPARE: { fill: 'rgba(160,80,0,0.07)', stroke: '#884400', dash: [2,4], w: 1 }, }; const _zoneStyleMap = new Map(); Object.entries(_ZONE_CFG).forEach(([layer, cfg]) => { _zoneStyleMap.set(layer, new ol.style.Style({ fill: new ol.style.Fill({ color: cfg.fill }), stroke: new ol.style.Stroke({ color: cfg.stroke, width: cfg.w, lineDash: cfg.dash || undefined }), })); }); function zoneStyle(feature, resolution) { if (!_cellShouldRender(feature.get('cell'), resolution)) return null; return _zoneStyleMap.get(feature.get('layer')) || null; } // Track the last bbox we requested so we can skip redundant fetches when the // user pans only slightly. We expand the request bbox by a margin so small // pans don't always trigger a refetch. let _lastDepthBbox = null; function _bboxContains(outer, inner) { if (!outer || !inner) return false; return outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]; } window.loadChartDepths = async function() { if (!depthLayer.getVisible()) return; // nothing to do if hidden const view = map.getView(); const extent = view.calculateExtent(map.getSize()); // Convert from map projection (EPSG:3857) to lon/lat const [w, s, e, n] = ol.proj.transformExtent(extent, view.getProjection(), 'EPSG:4326'); // Skip if current cache already covers the visible viewport if (_bboxContains(_lastDepthBbox, [w, s, e, n])) return; // Request a slightly larger area than visible so small pans hit the cache const dx = (e - w) * 0.5, dy = (n - s) * 0.5; const W = w - dx, S = s - dy, E = e + dx, N = n + dy; try { const r = await fetch(`/charts/depths?w=${W}&s=${S}&e=${E}&n=${N}`); const fc = await r.json(); depthSource.clear(); soundSource.clear(); if (fc.features?.length) { const fmt = new ol.format.GeoJSON(); const all = fmt.readFeatures(fc, { featureProjection: 'EPSG:3857' }); // Route SOUNDG (numbers) to soundLayer (zIndex 8) so soundings render // above terrain. Everything else stays in depthLayer (zIndex 2). const sound = [], rest = []; for (const f of all) { (f.get('layer') === 'SOUNDG' ? sound : rest).push(f); } if (rest.length) depthSource.addFeatures(rest); if (sound.length) soundSource.addFeatures(sound); // Update the legend's unit label from whatever the chart provides const unit = fc.features[0].properties?.unit || 'METERS'; const legendTitle = document.querySelector('#depth-legend .dl-title'); if (legendTitle) legendTitle.textContent = `DEPTHS — ${unit}`; } _lastDepthBbox = [W, S, E, N]; console.log(`[ENC] Depths loaded: ${fc.features?.length || 0} in viewport`); } catch (e) { console.warn('Depth load error', e); } }; // Debounced reload on pan/zoom — fires ~300 ms after the user stops moving. let _depthReloadTimer = null; function _scheduleDepthReload() { clearTimeout(_depthReloadTimer); _depthReloadTimer = setTimeout(() => window.loadChartDepths(), 300); } window.loadChartLand = async function() { landSource.clear(); try { const r = await fetch('/charts/land'); const fc = await r.json(); if (!fc.features?.length) return; landSource.addFeatures( new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' })); console.log(`[ENC] Land: ${fc.features.length} features`); } catch(e) { console.warn('Land load error', e); } }; window.loadChartHazards = async function() { hazardSource.clear(); try { const r = await fetch('/charts/hazards'); const fc = await r.json(); if (!fc.features?.length) return; hazardSource.addFeatures( new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' })); console.log(`[ENC] Hazards: ${fc.features.length} features`); } catch(e) { console.warn('Hazard load error', e); } }; window.loadChartZones = async function() { zoneSource.clear(); try { const r = await fetch('/charts/zones'); const fc = await r.json(); if (!fc.features?.length) return; zoneSource.addFeatures( new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' })); console.log(`[ENC] Zones: ${fc.features.length} features`); } catch(e) { console.warn('Zones load error', e); } }; window.reloadAllChartLayers = function() { // Clear symbol cache so newly-built charts pick up fresh symbols Object.keys(_iconCache).forEach(k => delete _iconCache[k]); window.loadChartFeatures?.(); window.loadChartLand?.(); window.loadChartHazards?.(); window.loadChartZones?.(); if (depthLayer.getVisible()) { _lastDepthBbox = null; window.loadChartDepths?.(); } }; window.loadChartFeatures = async function() { encSource.clear(); try { const r = await fetch('/charts/features'); const fc = await r.json(); if (!fc.features?.length) return; // ── Merge LIGHTS into nearby structure features ──────────────────────── // S-57 encodes each buoy/beacon and its light as TWO separate objects. // They may be at exactly the same coordinates (NOAA pattern) or up to // ~50 m apart (≈0.00045°). We try exact match first, then proximity. // After merging: light attrs copied into host, LIGHT_POINT dropped. const MERGE_DEG = 0.00045; // ~50 m — matches backend proximity radius const structList = []; // [{ f, x, y }] — all structure features for (const f of fc.features) { const lyr = (f.properties?.layer || '').toUpperCase(); if (lyr.startsWith('BOY') || lyr.startsWith('BCN') || lyr === 'LNDMRK') { const [x, y] = f.geometry.coordinates; structList.push({ f, x, y }); } } // Fast exact-coord lookup (grid key rounded to 6 dp ≈ 0.11 m) const structByKey = new Map(); for (const s of structList) { structByKey.set(`${s.x.toFixed(6)}_${s.y.toFixed(6)}`, s.f); } const before = fc.features.length; fc.features = fc.features.filter(f => { if (f.properties?.aid_type !== 'LIGHT_POINT') return true; const [lx, ly] = f.geometry.coordinates; // 1) Try exact coordinate match let host = structByKey.get(`${lx.toFixed(6)}_${ly.toFixed(6)}`); // 2) Proximity fallback (~50 m radius) if (!host) { let bestDist = MERGE_DEG; for (const s of structList) { const d = Math.sqrt((lx - s.x) ** 2 + (ly - s.y) ** 2); if (d < bestDist) { bestDist = d; host = s.f; } } } if (!host) return true; // standalone light with no nearby structure — keep const hp = host.properties; const lp = f.properties; // Backfill colour from light if host has none if (!hp.colours?.length && lp.colours?.length) { hp.colours = lp.colours.slice(); // Infer CATLAM from colour when missing const region = hp.cell_region || 'B'; const ialaB = region === 'B'; const c0 = lp.colours[0]; if (!hp.catlam) { if (c0 === 4) hp.catlam = ialaB ? 1 : 2; else if (c0 === 3) hp.catlam = ialaB ? 2 : 1; } if (hp.catlam === 1) hp.aid_type = 'LATERAL_PORT'; else if (hp.catlam === 2) hp.aid_type = 'LATERAL_STBD'; } // Copy light character, range, height → these appear in the tooltip if (lp.light_desc && !hp.light_desc) hp.light_desc = lp.light_desc; if (lp.range_nm && !hp.range_nm) hp.range_nm = lp.range_nm; if (lp.height_m && !hp.height_m) hp.height_m = lp.height_m; // Copy orient so the buoy/beacon can anchor its leading line correctly if (lp.orient != null && hp.orient == null) hp.orient = lp.orient; return false; // drop the now-redundant LIGHT_POINT }); const dropped = before - fc.features.length; // ── Merge LDLINE pairs (enfilaciones) ────────────────────────────────── // Enfilaciones always work in PAIRS (or triples): two aligned marks on the // SAME bearing define one leading line. We get one LDLINE per mark but must // show only ONE continuous line projected from the outermost (seaward) mark. // Group by rounded bearing (0.5° resolution) and cell, then keep the one // line that spans from the outermost start to the farthest endpoint. const ldByKey = new Map(); // `${cell}_${roundedBearing}` → [feature, ...] for (const f of fc.features) { if (f.properties?.layer !== 'LDLINE') continue; const bear = Math.round((f.properties.orient || 0) * 2) / 2; const cell = f.properties.cell || ''; const key = `${cell}_${bear}`; if (!ldByKey.has(key)) ldByKey.set(key, []); ldByKey.get(key).push(f); } const ldRemove = new Set(); for (const [, group] of ldByKey) { if (group.length <= 1) continue; // Project each mark's start-point onto the bearing direction vector. // The mark with SMALLEST projection is most seaward (outermost, front mark). const bear0 = (group[0].properties.orient || 0) * Math.PI / 180; const dx = Math.sin(bear0), dy = Math.cos(bear0); const proj = group.map(f => { const [x, y] = f.geometry.coordinates[0]; return { f, p: x * dx + y * dy, end: f.geometry.coordinates[1] }; }); proj.sort((a, b) => a.p - b.p); // Keep the outermost (front mark), extend its endpoint to the farthest inland end const keeper = proj[0].f; // Farthest inland endpoint (highest projection value) keeper.geometry.coordinates[1] = proj[proj.length - 1].end; // Build the name from all paired marks combined const names = [...new Set(proj.map(x => x.f.properties.name).filter(Boolean))]; if (names.length > 1) keeper.properties.name = names.join(' — '); // Drop all others for (const { f } of proj.slice(1)) ldRemove.add(f); } fc.features = fc.features.filter(f => !ldRemove.has(f)); const layerCounts = {}; fc.features.forEach(f => { const l = f.properties?.layer || 'unknown'; layerCounts[l] = (layerCounts[l] || 0) + 1; }); console.log(`[ENC] Features by layer (${dropped} duplicate lights dropped):`, layerCounts); const fmt = new ol.format.GeoJSON(); const features = fmt.readFeatures(fc, { featureProjection: 'EPSG:3857' }); encSource.addFeatures(features); } catch(e) { console.warn('ENC load error', e); } }; window.loadChartFeatures(); window.loadChartLand(); window.loadChartHazards(); window.loadChartZones(); const map = new ol.Map({ target: 'map', layers: [osmLayer, oceanRefLayer, depthLayer, landLayer, zoneLayer, seaMapLayer, encLayer, hazardLayer, soundLayer, vesselsLayer, aidsLayer], view: new ol.View({ center: ol.proj.fromLonLat([MAP_CENTER_LON, MAP_CENTER_LAT]), zoom: MAP_ZOOM, minZoom: 5, }), controls: ol.control.defaults.defaults({ attribution: false }), }); // ── Estilos ──────────────────────────────────────────────────────────────── // Color escala por LOA (matching AR ECDIS) — la longitud es el dato útil // para evitar colisiones; el tipo AIS es categoría administrativa. function vesselColorByLength(L) { if (L >= 300) return '#ff3333'; // VLCC, ULCS if (L >= 200) return '#ff8800'; // large container/cruise if (L >= 100) return '#ffd700'; // medium if (L >= 50) return '#3399ff'; // coastal if (L >= 20) return '#99ddff'; // small craft return '#aaffaa'; // yacht / fishing } // Compute ship silhouette polygon as ring of [lon,lat] coords. // Reference point (AIS antenna) at the given (lat,lon). // Bow points along heading (clockwise degrees from true north). function _computeShipPolygon(lat, lon, headingDeg, length, beam, toBow, toStern, toPort, toStarboard) { const L = length || 50; const B = beam || L * 0.15; const Tb = toBow ?? L / 2; const Ts = toStern ?? (L - Tb); const Tp = toPort ?? B / 2; const Tsb = toStarboard ?? (B - Tp); // Ship-local frame (m): x=starboard, y=bow. Origin = antenna. // Pointed bow, parallel sides, square stern with small notch. const local = [ [0, Tb], // bow tip [Tsb, Tb * 0.65], // starboard shoulder [Tsb, -Ts * 0.50], // mid-starboard [Tsb * 0.7, -Ts], // stern starboard corner [0, -Ts * 0.92], // stern notch [-Tp * 0.7, -Ts], // stern port corner [-Tp, -Ts * 0.50], // mid-port [-Tp, Tb * 0.65], // port shoulder [0, Tb], // close ]; const h = headingDeg * Math.PI / 180; const cos = Math.cos(h), sin = Math.sin(h); const mPerDegLat = 111320; const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180); return local.map(([x, y]) => { // Rotate clockwise from north: east = x*cos + y*sin, north = -x*sin + y*cos const e = x * cos + y * sin; const n = -x * sin + y * cos; return [lon + e / mPerDegLon, lat + n / mPerDegLat]; }); } function vesselStyle(feature, resolution) { const heading = feature.get('heading') ?? feature.get('cog') ?? 0; const length = feature.get('length') || 0; const tipo = feature.get('tipo') || 0; const color = length > 0 ? vesselColorByLength(length) : vesselColor(tipo); const geom = feature.getGeometry(); const isPoly = geom && geom.getType() === 'Polygon'; const labelText = feature.get('nombre') || feature.get('mmsi'); const styles = []; if (isPoly) { // Pixel size of silhouette at current resolution — decides whether to // add a fallback icon for very-zoomed-out views. const ext = geom.getExtent(); const sizePx = Math.max(ext[2] - ext[0], ext[3] - ext[1]) / resolution; styles.push(new ol.style.Style({ fill: new ol.style.Fill({ color }), stroke: new ol.style.Stroke({ color: '#000', width: 1 }), })); // COG vector — dashed line ahead of the bow (1 minute of travel) const sog = feature.get('sog') || 0; if (sog > 0.5) { const cog = feature.get('cog') ?? heading; const lat = feature.get('lat'), lon = feature.get('lon'); const distM = sog * 0.514444 * 60; // 1 minute @ SOG knots const h = cog * Math.PI / 180; const mPerDegLat = 111320; const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180); const tipLon = lon + (distM * Math.sin(h)) / mPerDegLon; const tipLat = lat + (distM * Math.cos(h)) / mPerDegLat; const fromCoord = ol.proj.fromLonLat([lon, lat]); const toCoord = ol.proj.fromLonLat([tipLon, tipLat]); styles.push(new ol.style.Style({ geometry: new ol.geom.LineString([fromCoord, toCoord]), stroke: new ol.style.Stroke({ color, width: 1.5, lineDash: [4, 4], }), })); } // If the silhouette is too small to perceive, stamp the legacy ship icon // on the centroid so the vessel stays visible at world zoom. if (sizePx < 6) { const ctr = ol.extent.getCenter(ext); const canvas = _cachedIcon(`ship_${color}`, () => _drawShip(color)); styles.push(new ol.style.Style({ geometry: new ol.geom.Point(ctr), image: new ol.style.Icon({ img: canvas, imgSize: [canvas.width, canvas.height], rotation: ol.math.toRadians(heading), rotateWithView: false, }), })); } styles.push(new ol.style.Style({ geometry: new ol.geom.Point(ol.extent.getCenter(ext)), text: new ol.style.Text({ text: labelText, offsetY: sizePx > 10 ? -(sizePx / 2) - 8 : 16, font: '500 10px Inter, sans-serif', fill: new ol.style.Fill({ color: '#e0ecf8' }), stroke: new ol.style.Stroke({ color: '#030810', width: 3 }), }), })); } else { // Point feature — vessel reported position only, no dimensions yet. const canvas = _cachedIcon(`ship_${color}`, () => _drawShip(color)); styles.push(new ol.style.Style({ image: new ol.style.Icon({ img: canvas, imgSize: [canvas.width, canvas.height], rotation: ol.math.toRadians(heading), rotateWithView: false, }), text: new ol.style.Text({ text: labelText, offsetY: 20, font: '500 10px Inter, sans-serif', fill: new ol.style.Fill({ color: '#e0ecf8' }), stroke: new ol.style.Stroke({ color: '#030810', width: 3 }), }), })); } return styles; } function vesselColor(tipo) { if (tipo >= 60 && tipo < 70) return '#06b6d4'; // pasajeros — cyan if (tipo >= 70 && tipo < 80) return '#f59e0b'; // cargo — amber if (tipo >= 80 && tipo < 90) return '#dc2626'; // tanquero — red if (tipo === 52 || tipo === 21) return '#f97316'; // remolcador — orange if (tipo === 30) return '#22c55e'; // pesquero — green return '#94a3b8'; // default — slate } // ── Status dot: small alert overlay on top-right corner ───────────────────── // Only painted when there's an anomaly so symbols stay clean in normal state. // amber = fuera de posición red = en movimiento / alarma function _stampStatus(canvas, enPos, enMov) { if (!enMov && enPos !== false) return; // normal state → leave symbol clean const ctx = canvas.getContext('2d'); const r = 2.5; const x = canvas.width - r - 0.5, y = r + 0.5; const dotColor = enMov ? '#ff3333' : '#fbbf24'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = dotColor; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 0.8; ctx.stroke(); } // ── Position dot: small black dot at bottom-center = geographic anchor point ── function _posDot(canvas) { const ctx = canvas.getContext('2d'); const cx = canvas.width / 2; ctx.beginPath(); ctx.arc(cx, canvas.height - 2, 2.5, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 0.8; ctx.stroke(); } // Like _cachedIcon but always adds the position dot after drawing. function _aidIcon(key, drawFn) { if (!_iconCache[key]) { const c = drawFn(); _posDot(c); _iconCache[key] = c; } return _iconCache[key]; } // ── Infer port/starboard from the aid's nombre ──────────────────────────────── function _isPortSide(nombre, ialaB) { const n = (nombre || '').toLowerCase(); if (n.includes('babor') || n.includes('izquierda') || n.includes('larboard')) return true; if (n.includes('estribor') || n.includes('derecha') || n.includes('starboard')) return false; // Colour hints: IALA-B port=green, stbd=red; IALA-A inverted if (ialaB) { if (n.includes('verde') || n.includes('green')) return true; if (n.includes('rojo') || n.includes('red')) return false; } else { if (n.includes('rojo') || n.includes('red')) return true; if (n.includes('verde') || n.includes('green')) return false; } return true; // default to port when ambiguous } // ── Cardinal quadrant from name ─────────────────────────────────────────────── function _cardinalQ(nombre) { const n = (nombre || '').toUpperCase(); if (n.includes('SUR') || n.includes('SOUTH')) return 'S'; if (n.includes('ESTE') || n.includes('EAST')) return 'E'; if (n.includes('OESTE') || n.includes('WEST') || n.includes('NW') || n.includes('SW')) { if (n.includes('NW') || n.includes('NOROESTE')) return 'NW'; if (n.includes('SW') || n.includes('SUROESTE')) return 'SW'; return 'W'; } if (n.includes('NE') || n.includes('NORESTE')) return 'NE'; if (n.includes('SE') || n.includes('SURESTE')) return 'SE'; return 'N'; } function aidStyle(feature) { const tipo = (feature.get('tipo') || '').toUpperCase(); const enPos = feature.get('en_posicion') !== false; const enMov = feature.get('en_movimiento') === true; const nombre = feature.get('nombre') || ''; const ialaB = window.IALA_REGION === 'B'; // S-57 colour codes: 3=Red 4=Green const portCode = ialaB ? 4 : 3; const stbdCode = ialaB ? 3 : 4; const stat = enMov ? 'm' : enPos ? 'p' : 'o'; let canvas; if (tipo === 'FARO') { canvas = _aidIcon(`faro_${stat}`, () => { const c = _drawLighthouse('#f9a825', 28); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'FAROL') { canvas = _aidIcon(`farol_${stat}`, () => { const c = _drawLighthouse('#f9a825', 22); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'BOYA_LATERAL') { // Symbols ported from AR ECDIS — port=can, stbd=cone, colour by region. const isPort = _isPortSide(nombre, ialaB); const side = isPort ? 'P' : 'S'; canvas = _cachedIcon(`iala_lat_${side}_${ialaB}_${stat}`, () => { const c = isPort ? _ialaBoyLatPort(ialaB) : _ialaBoyLatStbd(ialaB); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'BOYA_CARDINAL') { // Reduce intercardinals (NE/SE/NW/SW) to the 4 main quadrants. let q = _cardinalQ(nombre); if (!['N','S','E','W'].includes(q)) q = q[0]; canvas = _cachedIcon(`iala_car_${q}_${stat}`, () => { const c = _ialaBoyCar(q); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'BOYA_PELIGRO') { canvas = _cachedIcon(`iala_isd_${stat}`, () => { const c = _ialaBoyIsd(); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'BOYA_AGUAS_SEGURAS') { canvas = _cachedIcon(`iala_saw_${stat}`, () => { const c = _ialaBoySaw(); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'BOYA_ESPECIAL' || tipo.startsWith('BOYA')) { canvas = _cachedIcon(`iala_spp_${stat}`, () => { const c = _ialaBoySpp(); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'BALIZA') { const isPort = _isPortSide(nombre, ialaB); const catlam = isPort ? 1 : 2; const col = isPort ? portCode : stbdCode; const side = isPort ? 'P' : 'S'; canvas = _aidIcon(`baliza_lat_${side}_${ialaB}_${stat}`, () => { const c = _encBeaconCanvas([col], catlam, ialaB ? 'B' : 'A'); _stampStatus(c, enPos, enMov); return c; }); } else if (tipo === 'ENFILACION') { canvas = _aidIcon(`enfil_${stat}`, () => { const c = _drawTriangle('#f9a825', '#c8780a', 22); _stampStatus(c, enPos, enMov); return c; }); } else { const fill = enMov ? '#e53935' : (enPos ? '#1db954' : '#f59e0b'); const stroke = enMov ? '#ff6b6b' : (enPos ? '#4ade80' : '#fcd34d'); canvas = _aidIcon(`aid_gen_${stat}`, () => _drawDiamond(fill, stroke)); } return new ol.style.Style({ image: new ol.style.Icon({ img: canvas, imgSize: [canvas.width, canvas.height], scale: 1, anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction', }), text: new ol.style.Text({ text: nombre, offsetY: 8, font: '500 9px Inter, sans-serif', fill: new ol.style.Fill({ color: '#cbd5e1' }), stroke: new ol.style.Stroke({ color: '#030810', width: 3 }), }), }); } // ── Actualizar features ──────────────────────────────────────────────────── window.updateVessel = function(data) { let feature = vesselsSource.getFeatureById(data.mmsi); const headingForGeom = data.heading ?? data.cog ?? 0; // Build silhouette polygon if we have dimensions, fall back to Point. let geometry; if (data.length && data.beam) { const ringLL = _computeShipPolygon( data.lat, data.lon, headingForGeom, data.length, data.beam, data.to_bow, data.to_stern, data.to_port, data.to_starboard ); const ring = ringLL.map(c => ol.proj.fromLonLat(c)); geometry = new ol.geom.Polygon([ring]); } else { geometry = new ol.geom.Point(ol.proj.fromLonLat([data.lon, data.lat])); } if (!feature) { feature = new ol.Feature({ geometry }); feature.setId(data.mmsi); vesselsSource.addFeature(feature); } else { feature.setGeometry(geometry); } feature.setProperties({ featureType: 'vessel', mmsi: data.mmsi, nombre: data.nombre, tipo: data.tipo, tipo_nombre: data.tipo_nombre, sog: data.sog, cog: data.cog, heading: data.heading, lat: data.lat, lon: data.lon, length: data.length, beam: data.beam, to_bow: data.to_bow, to_stern: data.to_stern, to_port: data.to_port, to_starboard: data.to_starboard, // Static AIS Type 5 / Type 24 imo: data.imo, callsign: data.callsign, bandera: data.bandera, draught: data.draught, eta: data.eta, // Dynamic Type 1/2/3 nav_status: data.nav_status, nav_status_name: data.nav_status_name, rot: data.rot, fix_type: data.fix_type, destino: data.destino, timestamp: data.timestamp, }); // Pass the function so OL re-evaluates per resolution (silhouette ↔ icon). feature.setStyle(vesselStyle); recordPosition(data.mmsi, data.lat, data.lon); const n = vesselsSource.getFeatures().length; document.getElementById('vessel-count').textContent = n; }; window.updateAid = function(data) { let feature = aidsSource.getFeatureById(data.id); const lat = data.lat_actual ?? data.lat_nominal; const lon = data.lon_actual ?? data.lon_nominal; const coord = ol.proj.fromLonLat([lon, lat]); if (!feature) { feature = new ol.Feature({ geometry: new ol.geom.Point(coord) }); feature.setId(data.id); aidsSource.addFeature(feature); } else { feature.getGeometry().setCoordinates(coord); } feature.setProperties({ featureType: 'aid', ...data }); feature.setStyle(aidStyle(feature)); // Limpiar símbolo solo si volvió a posición Y no hay alerta activa registrada if (data.en_posicion === true && !data.en_movimiento) { const id = data.id; const alerts = window.activeAlerts; const hasAlert = alerts && (alerts.has(`ALERTA_ROJA__${id}`) || alerts.has(`ALERTA_AMARILLA__${id}`)); if (!hasAlert) window.setAidWarning?.(feature, null); } const n = aidsSource.getFeatures().length; document.getElementById('aid-count').textContent = n; }; // ── Tooltip ──────────────────────────────────────────────────────────────── const tooltip = document.getElementById('tooltip'); // Layers where hover should NOT pop a tooltip — land/depth areas cover huge // regions and would fire on every mouse move over terrain. // BUISGL (buildings) and point landmarks are intentionally NOT in this set. const _NO_HOVER = new Set([ 'SOUNDG', 'DEPCNT', 'DEPARE', 'LNDARE', 'LANDMASK', 'BUAARE', 'COALNE', // land — no tooltip 'LDLINE', // leading lines — no tooltip (bearing shown as label) ]); map.on('pointermove', (evt) => { // Skip depth features for hover detection. hitTolerance:8 so small // silhouettes (e.g. a 300m vessel renders ~4px wide at zoom 11) are still // hoverable without pixel-perfect aiming. const feature = map.forEachFeatureAtPixel(evt.pixel, f => _NO_HOVER.has(f.get('layer')) ? null : f, { hitTolerance: 8 }); if (!feature) { tooltip.classList.add('hidden'); map.getTargetElement().style.cursor = ''; return; } const p = feature.getProperties(); tooltip.classList.remove('hidden'); tooltip.style.left = (evt.originalEvent.clientX + 14) + 'px'; tooltip.style.top = (evt.originalEvent.clientY - 10) + 'px'; map.getTargetElement().style.cursor = 'pointer'; if (p.featureType === 'vessel') { const dims = (p.length && p.beam) ? `${p.length} × ${p.beam} m` : '--'; tooltip.innerHTML = `