'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 → [r,g,b] 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 above 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); 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); 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; } // ── Light symbol — slim vertical post with a teardrop flame on top. // Colour follows the emitted light: white, red, green, yellow. function _ialaLight(colourCode) { const W = 14, H = 34; const c = document.createElement('canvas'); c.width = W; c.height = H; const ctx = c.getContext('2d'); let col = '#ffffff'; if (colourCode === 3) col = '#dd0000'; else if (colourCode === 4) col = '#00aa00'; else if (colourCode === 6) col = '#ffcc00'; const cx = W / 2; // Slim vertical post (the structure) ctx.fillStyle = col; ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.fillRect(cx - 1.25, 12, 2.5, 18); ctx.strokeRect(cx - 1.25, 12, 2.5, 18); // Teardrop flame on top (apex up, rounded base sitting on the post) ctx.beginPath(); ctx.moveTo(cx, 1); ctx.bezierCurveTo(cx + 4.5, 3.5, cx + 4.0, 9, cx, 11.5); ctx.bezierCurveTo(cx - 4.0, 9, cx - 4.5, 3.5, cx, 1); ctx.closePath(); ctx.fillStyle = col; ctx.fill(); ctx.lineWidth = 0.8; ctx.strokeStyle = '#111'; ctx.stroke(); // Position dot at the bottom (geo anchor) 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, 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) — 3-D tripod + pole + topmark ────────────────── // Farillos de orilla: estructura fija (no flota). Tríangulo de patas + mástil // vertical + marca de tope cuadrada (babor) o cónica (estribor). function _encBeaconCanvas(colours, catlam, region, sz = 44) { 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; // Layout const groundY = sz * 0.92; // ground level (shadow base) const legHub = sz * 0.72; // where tripod legs converge const poleBot = legHub; const poleTop = sz * 0.32; // top of mast const tmH = sz * 0.18; // topmark height const tmW = sz * 0.46; // topmark width const tmY = poleTop; // topmark sits at poleTop // ── Subtle glow ────────────────────────────────────────────────────────── const glow = ctx.createRadialGradient(cx, sz * 0.60, 0, cx, sz * 0.60, sz * 0.44); glow.addColorStop(0, 'rgba(255,255,255,0.0)'); glow.addColorStop(0.65, 'rgba(180,210,255,0.10)'); glow.addColorStop(1, 'rgba(100,160,255,0.20)'); ctx.fillStyle = glow; ctx.fillRect(0, 0, sz, sz); // ── Ground shadow ───────────────────────────────────────────────────────── ctx.beginPath(); ctx.ellipse(cx, groundY, sz * 0.22, sz * 0.04, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill(); // ── Tripod legs (3 legs, perspective depth) ─────────────────────────────── // Back leg (darker, stub) ctx.beginPath(); ctx.moveTo(cx, legHub); ctx.lineTo(cx + sz*0.07, groundY * 0.97); ctx.strokeStyle = _darken3D(col, 0.50); ctx.lineWidth = 1.2; ctx.stroke(); // Left leg ctx.beginPath(); ctx.moveTo(cx, legHub); ctx.lineTo(cx - sz*0.28, groundY); ctx.strokeStyle = _darken3D(col, 0.25); ctx.lineWidth = 1.4; ctx.stroke(); ctx.strokeStyle = _lighten3D(col, 0.15); ctx.lineWidth = 0.5; ctx.stroke(); // Right leg ctx.beginPath(); ctx.moveTo(cx, legHub); ctx.lineTo(cx + sz*0.28, groundY); ctx.strokeStyle = _darken3D(col, 0.30); ctx.lineWidth = 1.4; ctx.stroke(); ctx.strokeStyle = _lighten3D(col, 0.10); ctx.lineWidth = 0.5; ctx.stroke(); // ── Hub ring where legs meet ────────────────────────────────────────────── const hubGrd = ctx.createRadialGradient(cx - sz*0.05, legHub - sz*0.02, 1, cx, legHub, sz*0.11); hubGrd.addColorStop(0, _lighten3D(col, 0.45)); hubGrd.addColorStop(1, _darken3D(col, 0.30)); ctx.beginPath(); ctx.ellipse(cx, legHub, sz*0.11, sz*0.038, 0, 0, Math.PI*2); ctx.fillStyle = hubGrd; ctx.fill(); ctx.strokeStyle = '#333'; ctx.lineWidth = 0.5; ctx.stroke(); // ── Mast / pole (thin cylinder with 3-D shading) ───────────────────────── const pw = sz * 0.07; const mastGrd = ctx.createLinearGradient(cx - pw/2, 0, cx + pw/2, 0); mastGrd.addColorStop(0, _darken3D('#888', 0.15)); mastGrd.addColorStop(0.35, '#cccccc'); mastGrd.addColorStop(1, _darken3D('#888', 0.42)); ctx.fillStyle = mastGrd; ctx.fillRect(cx - pw/2, poleTop, pw, poleBot - poleTop); ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 0.4; ctx.strokeRect(cx - pw/2, poleTop, pw, poleBot - poleTop); // ── Topmark (3-D) ───────────────────────────────────────────────────────── if (isPort) { // Square CAN — 3-D cylinder (short, wide) const tg = ctx.createLinearGradient(cx - tmW/2, 0, cx + tmW/2, 0); tg.addColorStop(0, _lighten3D(col, 0.45)); tg.addColorStop(0.42, col); tg.addColorStop(1, _darken3D(col, 0.45)); ctx.fillStyle = tg; ctx.fillRect(cx - tmW/2, tmY - tmH, tmW, tmH); // Top highlight ellipse const ery = tmH * 0.13; const eg = ctx.createRadialGradient(cx - tmW*0.25, tmY - tmH, 1, cx, tmY - tmH, tmW/2); eg.addColorStop(0, 'rgba(255,255,255,0.70)'); eg.addColorStop(1, col); ctx.beginPath(); ctx.ellipse(cx, tmY - tmH, tmW/2, ery, 0, 0, Math.PI*2); ctx.fillStyle = eg; ctx.fill(); // Bottom ellipse (shadow) ctx.beginPath(); ctx.ellipse(cx, tmY, tmW/2, ery, 0, 0, Math.PI*2); ctx.fillStyle = _darken3D(col, 0.45); ctx.fill(); ctx.strokeStyle = '#222'; ctx.lineWidth = 0.6; ctx.strokeRect(cx - tmW/2, tmY - tmH, tmW, tmH); } else { // CONE pointing up — 3-D gradient const apexY = tmY - tmH, baseY2 = tmY; const tg = ctx.createLinearGradient(cx - tmW/2, 0, cx + tmW/2, 0); tg.addColorStop(0, _lighten3D(col, 0.45)); tg.addColorStop(0.42, col); tg.addColorStop(1, _darken3D(col, 0.45)); ctx.beginPath(); ctx.moveTo(cx, apexY); ctx.lineTo(cx + tmW/2, baseY2); ctx.lineTo(cx - tmW/2, baseY2); ctx.closePath(); ctx.fillStyle = tg; ctx.fill(); // Base ellipse const ery2 = tmH * 0.10; ctx.beginPath(); ctx.ellipse(cx, baseY2, tmW/2, ery2, 0, 0, Math.PI*2); ctx.fillStyle = _darken3D(col, 0.40); ctx.fill(); // Highlight streak ctx.beginPath(); ctx.moveTo(cx - tmW*0.05, apexY + tmH*0.08); ctx.lineTo(cx - tmW*0.28, baseY2 - ery2); ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = tmW * 0.07; ctx.stroke(); ctx.strokeStyle = '#222'; ctx.lineWidth = 0.6; ctx.beginPath(); ctx.moveTo(cx, apexY); ctx.lineTo(cx+tmW/2, baseY2); ctx.lineTo(cx-tmW/2, baseY2); ctx.closePath(); ctx.stroke(); } return c; } // ── Range / Leading-mark dayboard — dos triángulos concéntricos 3-D ───────── // Outer triangle: WHITE (visible against any background) // Inner triangle: BLACK (center — classic range-mark dayboard pattern) // Both point UP. Mounted on a 3-D post with tripod base and optional light flare. // This symbol is used for ANY LIGHTS/LNDMRK feature that carries an ORIENT // attribute, meaning it defines a leading-line bearing. function _encRangeDaymarkCanvas(colours, hasLight, sz = 52) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; // ── Layout ────────────────────────────────────────────────────────────── const groundY = sz * 0.92; const legHub = sz * 0.72; const poleTop = sz * 0.42; // dayboard starts here (base of board) const boardBot = poleTop; const boardTop = sz * 0.08; // apex of outer triangle const boardW = sz * 0.80; // max width of outer triangle at its base // Light colour (top dot) from feature colour code const lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff'; // ── Ground shadow ──────────────────────────────────────────────────────── ctx.beginPath(); ctx.ellipse(cx, groundY, sz * 0.20, sz * 0.04, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill(); // ── Tripod base ────────────────────────────────────────────────────────── // Back stub ctx.beginPath(); ctx.moveTo(cx, legHub); ctx.lineTo(cx + sz*0.06, groundY * 0.97); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.0; ctx.stroke(); // Left leg ctx.beginPath(); ctx.moveTo(cx, legHub); ctx.lineTo(cx - sz*0.26, groundY); ctx.strokeStyle = '#666'; ctx.lineWidth = 1.3; ctx.stroke(); ctx.strokeStyle = '#aaa'; ctx.lineWidth = 0.4; ctx.stroke(); // Right leg ctx.beginPath(); ctx.moveTo(cx, legHub); ctx.lineTo(cx + sz*0.26, groundY); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.3; ctx.stroke(); ctx.strokeStyle = '#999'; ctx.lineWidth = 0.4; ctx.stroke(); // ── Hub ring ───────────────────────────────────────────────────────────── ctx.beginPath(); ctx.ellipse(cx, legHub, sz*0.09, sz*0.030, 0, 0, Math.PI*2); const hubG = ctx.createRadialGradient(cx - sz*0.04, legHub, 1, cx, legHub, sz*0.09); hubG.addColorStop(0, '#ccc'); hubG.addColorStop(1, '#555'); ctx.fillStyle = hubG; ctx.fill(); ctx.strokeStyle = '#333'; ctx.lineWidth = 0.4; ctx.stroke(); // ── Mast / pole ─────────────────────────────────────────────────────────── const pw = sz * 0.07; const mastG = ctx.createLinearGradient(cx - pw/2, 0, cx + pw/2, 0); mastG.addColorStop(0, '#999'); mastG.addColorStop(0.3, '#eee'); mastG.addColorStop(1, '#666'); ctx.fillStyle = mastG; ctx.fillRect(cx - pw/2, poleTop, pw, legHub - poleTop); ctx.strokeStyle = '#333'; ctx.lineWidth = 0.4; ctx.strokeRect(cx - pw/2, poleTop, pw, legHub - poleTop); // ── OUTER triangle (WHITE) — 3-D face with gradient ────────────────────── // The triangle board catches sunlight from upper-left → left face lighter, // right face darker, giving it a 3-D planar look. const outerGrad = ctx.createLinearGradient(cx - boardW/2, 0, cx + boardW/2, 0); outerGrad.addColorStop(0, 'rgba(255,255,255,1.00)'); // lit left edge outerGrad.addColorStop(0.40,'rgba(240,240,240,1.00)'); // face outerGrad.addColorStop(1, 'rgba(190,190,190,1.00)'); // shadow right ctx.beginPath(); ctx.moveTo(cx, boardTop); ctx.lineTo(cx + boardW / 2, boardBot); ctx.lineTo(cx - boardW / 2, boardBot); ctx.closePath(); ctx.fillStyle = outerGrad; ctx.fill(); // Glow outline ctx.shadowBlur = 6; ctx.shadowColor = 'rgba(255,255,255,0.8)'; ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 1.2; ctx.stroke(); ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; ctx.strokeStyle = '#555'; ctx.lineWidth = 0.7; ctx.stroke(); // ── INNER triangle (BLACK) — 3-D face ──────────────────────────────────── // Inner tri is 45% the size of the outer, centred on the board face. // Its apex is at (cx, boardTop + inset) and base is near boardBot. const iW = boardW * 0.45; const iTop = boardTop + (boardBot - boardTop) * 0.18; // inset from apex const iBot = boardBot - (boardBot - boardTop) * 0.08; // inset from base const innerGrad = ctx.createLinearGradient(cx - iW/2, 0, cx + iW/2, 0); innerGrad.addColorStop(0, '#3a3a3a'); // lit left face (dark but not pure black) innerGrad.addColorStop(0.45,'#111111'); // center innerGrad.addColorStop(1, '#000000'); // shadow right ctx.beginPath(); ctx.moveTo(cx, iTop); ctx.lineTo(cx + iW / 2, iBot); ctx.lineTo(cx - iW / 2, iBot); ctx.closePath(); ctx.fillStyle = innerGrad; ctx.fill(); // Left-edge highlight streak (3-D illusion) ctx.beginPath(); ctx.moveTo(cx - iW * 0.04, iTop + (iBot - iTop) * 0.06); ctx.lineTo(cx - iW * 0.30, iBot - (iBot - iTop) * 0.05); ctx.strokeStyle = 'rgba(120,120,120,0.55)'; ctx.lineWidth = iW * 0.06; ctx.stroke(); ctx.strokeStyle = '#000'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(cx, iTop); ctx.lineTo(cx+iW/2, iBot); ctx.lineTo(cx-iW/2, iBot); ctx.closePath(); ctx.stroke(); // ── Light flare at apex (if feature has a light) ─────────────────────────── if (hasLight) { _drawLightFlare(ctx, cx, boardTop - 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]. // maxZoom = 24 for ALL bands — never cut a cell because of an upper zoom limit. // Hiding lower-band cells when a higher-band cell is available is handled // exclusively by _recomputeHiddenCells (≥40% viewport overlap). // Without this, a custom cell like "CO1CO01M" (band-1 per IHO cell-ID convention) // would disappear above zoom 10 even though it's the only chart in its area. const BAND_ZOOM_RANGES = { 0: [0, 24], // custom / unknown → always visible 1: [0, 24], // Overview (1:600 000+) 2: [3, 24], // General (1:350 000) 3: [6, 24], // Coastal (1:90 000) 4: [8, 24], // 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.22 at zoom 7, 0.50 at zoom 14+ — half the original canvas size. const iconScale = Math.max(0.22, Math.min(0.50, 0.22 + (_zr - 7) * 0.04)); 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 = `