'use strict'; let MAP_CENTER_LAT = 11.0041; // Barranquilla — default for admin/superadmin let MAP_CENTER_LON = -74.8070; let 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(); // Ghost layer: AIS-reported actual position when it differs from nominal. // Rendered translucent so the nominal marker stays the "well-marked" one. const aisGhostSource = 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 }); const aisGhostLayer = new ol.layer.Vector({ source: aisGhostSource, zIndex: 21, // sits just above the nominal aid so it's visible declutter: false, }); // ── 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, // 12000 m/px ≈ zoom 6 — layer visible from here; encStyle scales symbols // down smoothly so they shrink instead of blinking out as user zooms away. // Per-cell band ranges inside _cellShouldRender still refine visibility. maxResolution: 12000, // declutter: false — aids are physical objects that don't overlap in practice. // With declutter:true, ol.style.Icon gets hidden by collision but ol.style.Circle // (the position dot) does NOT participate in declutter, leaving ghost dots. declutter: false, }); // ── 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]; } // ── Soft-blur wrapper for aid canvases ──────────────────────────────────── // Applies a gentle gaussian blur to soften jagged canvas edges. // Uses a WeakMap so each source canvas is blurred exactly ONCE — the result // is reused on every subsequent style call (same cost as the cached original). const _blurCache = new WeakMap(); function _softCanvas(src, blurPx = 0.7) { if (_blurCache.has(src)) return _blurCache.get(src); const dst = document.createElement('canvas'); dst.width = src.width; dst.height = src.height; const ctx = dst.getContext('2d'); ctx.filter = `blur(${blurPx}px)`; ctx.drawImage(src, 0, 0); _blurCache.set(src, dst); return dst; } // ── Ship canvas ─────────────────────────────────────────────────────────── function _drawShip(color, sz = 32) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; ctx.save(); ctx.translate(h, h); // hull ctx.beginPath(); ctx.moveTo(0, -h + 3); // bow ctx.bezierCurveTo(6, -4, 6, 4, 5, h - 3); // starboard ctx.lineTo(-5, h - 3); // stern ctx.bezierCurveTo(-6, 4, -6, -4, 0, -h + 3); // port ctx.closePath(); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.stroke(); // bridge rect ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(-3, -2, 6, 6); // bow arrow ctx.beginPath(); ctx.moveTo(0, -h + 2); ctx.lineTo(3, -h + 8); ctx.lineTo(-3, -h + 8); ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fill(); ctx.restore(); return c; } // ── Aid canvas helpers ──────────────────────────────────────────────────── function _drawCircleDiamond(fill, stroke, sz = 22) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // circle body ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); // inner diamond ctx.beginPath(); ctx.moveTo(h, 3); ctx.lineTo(sz - 3, h); ctx.lineTo(h, sz - 3); ctx.lineTo(3, h); ctx.closePath(); ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 1.5; ctx.stroke(); return c; } function _drawLighthouse(fill, sz = 26) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // 8-point star ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = (i * Math.PI) / 4; const r = i % 2 === 0 ? h - 1 : (h - 1) * 0.45; const x = h + Math.cos(a - Math.PI / 2) * r; const y = h + Math.sin(a - Math.PI / 2) * r; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); // center dot ctx.beginPath(); ctx.arc(h, h, 3, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); return c; } function _drawDiamond(fill, stroke, sz = 22) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; ctx.beginPath(); ctx.moveTo(h, 1); ctx.lineTo(sz - 1, h); ctx.lineTo(h, sz - 1); ctx.lineTo(1, h); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); return c; } function _drawTriangle(fill, stroke, sz = 22, inverted = false) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); ctx.beginPath(); if (!inverted) { ctx.moveTo(sz / 2, 1); ctx.lineTo(sz - 1, sz - 1); ctx.lineTo(1, sz - 1); } else { ctx.moveTo(1, 1); ctx.lineTo(sz - 1, 1); ctx.lineTo(sz / 2, sz - 1); } ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); return c; } function _drawCardinalBuoy(quadrant, sz = 24) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // Black/yellow banding by quadrant (IALA) const topBlack = ['N','NE','E','NW'].includes(quadrant); ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.fillStyle = topBlack ? '#000' : '#f5c518'; ctx.fill(); ctx.beginPath(); ctx.arc(h, h, h - 2, Math.PI, 0); ctx.fillStyle = topBlack ? '#f5c518' : '#000'; ctx.fill(); ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); // Arrows indicating safe water side ctx.fillStyle = '#fff'; ctx.font = `bold ${sz - 8}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const arrows = {N:'↑',S:'↓',E:'→',W:'←',NE:'↗',NW:'↖',SE:'↘',SW:'↙'}; ctx.fillText(arrows[quadrant] || '◆', h, h); return c; } function _drawSpecialMark(fill, sz = 20) { const c = document.createElement('canvas'); c.width = c.height = sz; const ctx = c.getContext('2d'); const h = sz / 2; // X shape (special mark — yellow cross) ctx.beginPath(); ctx.arc(h, h, h - 2, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(5, 5); ctx.lineTo(sz - 5, sz - 5); ctx.moveTo(sz - 5, 5); ctx.lineTo(5, sz - 5); ctx.stroke(); return c; } // ── S-57 colour code → CSS colour ───────────────────────────────────────── const S57_CSS = { 1:'#ffffff', 2:'#111111', 3:'#e53935', 4:'#2e7d32', 5:'#1565c0', 6:'#f9a825', 7:'#78909c', 8:'#6d4c41', 9:'#ff8f00', 10:'#7b1fa2', 11:'#ef6c00', 12:'#e91e63', }; function _s57css(code) { return S57_CSS[code] || '#78909c'; } /* ═══════════════════════════════════════════════════════════════════════════ IALA Nautical Symbols — exact silhouettes per IALA Maritime Buoyage System Reference: IALA Publication No.1, IHO INT-1, IALA Region A & B charts Layout per symbol (top→bottom): topmark → staff → body → waterline ═══════════════════════════════════════════════════════════════════════════ */ function _mkC(sz) { const c = document.createElement('canvas'); c.width = sz; c.height = sz; return c; } function _wl(ctx, cx, y, hw) { // waterline ctx.strokeStyle = '#1a6bb5'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx - hw, y); ctx.lineTo(cx + hw, y); ctx.stroke(); } function _st(ctx, cx, y1, y2) { // staff ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, y1); ctx.lineTo(cx, y2); ctx.stroke(); } // Topmark: cone pointing UP ▲ function _tmConeUp(ctx, cx, y0, w, h, fill) { ctx.beginPath(); ctx.moveTo(cx, y0); ctx.lineTo(cx + w/2, y0 + h); ctx.lineTo(cx - w/2, y0 + h); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); } // Topmark: cone pointing DOWN ▼ function _tmConeDown(ctx, cx, y0, w, h, fill) { ctx.beginPath(); ctx.moveTo(cx - w/2, y0); ctx.lineTo(cx + w/2, y0); ctx.lineTo(cx, y0 + h); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); } // Topmark: sphere ● function _tmSphere(ctx, cx, cy, r, fill) { ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.6; ctx.stroke(); } // Topmark: X cross function _tmX(ctx, cx, cy, r, col) { ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx-r, cy-r); ctx.lineTo(cx+r, cy+r); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx+r, cy-r); ctx.lineTo(cx-r, cy+r); ctx.stroke(); } // ── True-3D colour helpers ──────────────────────────────────────────────── function _h2r(hex) { // hex or rgb() → [r,g,b] if (typeof hex === 'string' && hex.startsWith('rgb')) { const m = hex.match(/\d+/g); return m ? [+m[0], +m[1], +m[2]] : [0, 0, 0]; } hex = (hex || '').replace('#',''); if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)]; } function _lighten3D(hex, f = 0.45) { const [r,g,b] = _h2r(hex); return `rgb(${Math.min(255,Math.round(r+(255-r)*f))},${Math.min(255,Math.round(g+(255-g)*f))},${Math.min(255,Math.round(b+(255-b)*f))})`; } function _darken3D(hex, f = 0.45) { const [r,g,b] = _h2r(hex); return `rgb(${Math.round(r*(1-f))},${Math.round(g*(1-f))},${Math.round(b*(1-f))})`; } // Cylindrical side gradient: bright left → main → dark right function _cylGrad(ctx, x, w, yMid, col) { const g = ctx.createLinearGradient(x, yMid, x + w, yMid); g.addColorStop(0, _lighten3D(col, 0.58)); g.addColorStop(0.22, _lighten3D(col, 0.18)); g.addColorStop(0.55, col); g.addColorStop(0.82, _darken3D(col, 0.32)); g.addColorStop(1, _darken3D(col, 0.55)); return g; } // Conical gradient: same as cyl but stronger contrast function _coneGrad(ctx, x, w, yMid, col) { const g = ctx.createLinearGradient(x, yMid, x + w, yMid); g.addColorStop(0, _lighten3D(col, 0.65)); g.addColorStop(0.20, _lighten3D(col, 0.20)); g.addColorStop(0.55, col); g.addColorStop(1, _darken3D(col, 0.60)); return g; } // Banded fill for multi-colour shapes, each band individually gradienterd function _fillBands3D(ctx, pathFn, bx, by, bw, bh, cssColours, gradFn) { ctx.save(); pathFn(ctx); ctx.clip(); if (!cssColours.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); } else { const bandH = bh / cssColours.length; cssColours.forEach((col, i) => { const y0 = by + i * bandH; ctx.fillStyle = gradFn(ctx, bx, bw, y0 + bandH / 2, col); ctx.fillRect(bx, y0, bw, bandH + 1); }); } ctx.restore(); } // Kept for backward compat with cardinal / isd / saw helpers function _fillBands(ctx, pathFn, bbox, cssColours) { const [bx, by, bw, bh] = bbox; ctx.save(); pathFn(ctx); ctx.clip(); if (!cssColours.length) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); } else { const bandH = bh / cssColours.length; cssColours.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(bx, by + i * bandH, bw, bandH + 0.6); }); } ctx.restore(); } // Kept for backward compat function _apply3DShade(ctx, pathFn, bbox) { const [bx, by, bw, bh] = bbox; ctx.save(); pathFn(ctx); ctx.clip(); const g = ctx.createLinearGradient(bx, by+bh/2, bx+bw, by+bh/2); g.addColorStop(0,'rgba(255,255,255,0.38)'); g.addColorStop(0.3,'rgba(255,255,255,0.06)'); g.addColorStop(0.65,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.32)'); ctx.fillStyle = g; ctx.fillRect(bx, by, bw, bh); ctx.restore(); } // ── IALA fallback colours when S-57 COLOUR is missing ───────────────────── // Returns S-57 colour CODES (not CSS) so they go through normal banding. function _ialaLateralColours(catlam, region) { const ialaB = (region || 'B') === 'B'; const G = 4, R = 3; const port = ialaB ? G : R; // port-hand colour const stbd = ialaB ? R : G; // starboard-hand colour if (catlam === 1) return [port]; if (catlam === 2) return [stbd]; if (catlam === 3) return [port, stbd, port]; // pref-channel-to-stbd: port w/ stbd band if (catlam === 4) return [stbd, port, stbd]; // pref-channel-to-port: stbd w/ port band return []; } // Fill a closed path with N horizontal bands (used for multi-colour buoys). function _fillBands(ctx, pathFn, bbox, cssColours) { const [bx, by, bw, bh] = bbox; ctx.save(); pathFn(ctx); ctx.clip(); if (cssColours.length === 0) { ctx.fillStyle = '#78909c'; ctx.fillRect(bx, by, bw, bh); } else { const bandH = bh / cssColours.length; cssColours.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(bx, by + i * bandH, bw, bandH + 0.6); }); } ctx.restore(); } // ══════════════════════════════════════════════════════════════════════════ // TRUE-3D BUOY SYMBOLS — geometry is built in 3 dimensions: // • Cylinders use side-rect + top-ellipse + bottom-ellipse // • Cones use face-triangle + base-ellipse + highlight streak // • Spheres use radial gradient with upper-left highlight // • Barrels use two bezier curves + elliptical ends // • Topmarks are correct per IALA: PORT = square cube, STBD = cone up // opts: {colours, boyshp, catlam, region, sz?, hasLight} // BOYSHP: 1=conical 2=can 3=sphere 4=pillar 5=spar 6=barrel 7=super-buoy // ══════════════════════════════════════════════════════════════════════════ function _encBuoyCanvas(opts) { const sz = opts.sz || 52; const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; let cc = (opts.colours || []).slice(); if (!cc.length && opts.catlam) cc = _ialaLateralColours(opts.catlam, opts.region); const css = cc.map(_s57css); const c1 = css[0] || '#78909c'; const ialaB = (opts.region || 'B') === 'B'; // ── Glow helper: apply outer glow on shape outlines via shadowBlur ────── // Call before strokeRect/stroke; reset after. col = CSS colour of the glow. const _glow = (col, blur = 4) => { ctx.shadowBlur = blur; ctx.shadowColor = col; }; const _noGlow = () => { ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; }; // ── Layout ────────────────────────────────────────────────────────────── const wlY = sz * 0.92; // waterline const bBot = sz * 0.88; // body bottom (waterline region) const bTop = sz * 0.52; // body top (pushed down for topmark room) // stTop must leave room for topmark above it (topmark height ≈ bW * 0.40) const _tmRoom = sz * 0.52 * 0.40; // topmark height reservation const stTop = opts.hasLight ? Math.max(sz * 0.20, _tmRoom + sz * 0.03) : Math.max(sz * 0.24, _tmRoom + sz * 0.03); const bW = sz * 0.52; // body width const bH = bBot - bTop; // body height const eRy = bH * 0.095; // ellipse y-radius for cylinder caps const bs = opts.boyshp, cl = opts.catlam; const isCan = bs === 2 || (!bs && (cl === 1 || cl === 3)); const isCone = bs === 1 || (!bs && (cl === 2 || cl === 4)); const isSph = bs === 3; const isSpar = bs === 5; const isBar = bs === 6; const isSuper = bs === 7; // ── Shared draw helpers ───────────────────────────────────────────────── // Glow colour: lighten primary colour, used for outline glow const glowCol = c1 ? _lighten3D(c1, 0.55) : 'rgba(255,255,255,0.7)'; const outln = (lw = 0.9) => { _noGlow(); ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = lw; ctx.stroke(); }; const outlnGlow = (lw = 1.2) => { _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = lw; ctx.stroke(); _noGlow(); }; // Draw a cylinder: sides + bottom ellipse + top ellipse const drawCylinder = (x, topY, w, h, colList) => { const ery = h * 0.095; // Side face (banded) _fillBands3D(ctx, cc => { cc.beginPath(); cc.rect(x, topY, w, h); }, x, topY, w, h, colList, _cylGrad); // Glow outline on side _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 1.0; ctx.strokeRect(x, topY, w, h); _noGlow(); ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 0.6; ctx.strokeRect(x, topY, w, h); // Bottom ellipse (shadow side) ctx.beginPath(); ctx.ellipse(x + w/2, topY + h, w/2, ery, 0, 0, Math.PI * 2); ctx.fillStyle = _darken3D(colList[colList.length-1] || c1, 0.5); ctx.fill(); outln(0.5); // Top ellipse with radial highlight const tg = ctx.createRadialGradient(x + w*0.3, topY, 1, x + w/2, topY, w/2); tg.addColorStop(0, 'rgba(255,255,255,0.80)'); tg.addColorStop(0.5, _lighten3D(colList[0] || c1, 0.25)); tg.addColorStop(1, colList[0] || c1); ctx.beginPath(); ctx.ellipse(x + w/2, topY, w/2, ery, 0, 0, Math.PI * 2); ctx.fillStyle = tg; ctx.fill(); outln(0.5); }; // Draw a cone: face + base ellipse + highlight const drawCone = (cx2, apexY, baseY, w, colList) => { const ery = (baseY - apexY) * 0.10; const bx = cx2 - w/2; // Body _fillBands3D(ctx, cc => { cc.beginPath(); cc.moveTo(cx2, apexY); cc.lineTo(cx2+w/2, baseY); cc.lineTo(cx2-w/2, baseY); cc.closePath(); }, bx, apexY, w, baseY - apexY, colList, _coneGrad); // Glow then outline ctx.beginPath(); ctx.moveTo(cx2, apexY); ctx.lineTo(cx2+w/2, baseY); ctx.lineTo(cx2-w/2, baseY); ctx.closePath(); _glow(glowCol, 5); ctx.strokeStyle = 'rgba(255,255,255,0.40)'; ctx.lineWidth = 1.0; ctx.stroke(); _noGlow(); ctx.beginPath(); ctx.moveTo(cx2, apexY); ctx.lineTo(cx2+w/2, baseY); ctx.lineTo(cx2-w/2, baseY); ctx.closePath(); outln(0.9); // Base ellipse ctx.beginPath(); ctx.ellipse(cx2, baseY, w/2, ery, 0, 0, Math.PI * 2); ctx.fillStyle = _darken3D(colList[colList.length-1] || c1, 0.40); ctx.fill(); outln(0.5); // Left-face highlight streak ctx.beginPath(); ctx.moveTo(cx2 - w * 0.05, apexY + (baseY-apexY)*0.08); ctx.lineTo(cx2 - w * 0.28, baseY - ery); ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = w * 0.07; ctx.stroke(); }; // Draw a sphere with radial gradient (multi-colour = horizontal stripes) const drawSphere = (cx2, cy2, r, colList) => { // Stripes via clip if (colList.length > 1) { const bandH = r * 2 / colList.length; colList.forEach((col, i) => { ctx.save(); ctx.beginPath(); ctx.rect(cx2-r, cy2-r + i*bandH, r*2, bandH+1); ctx.clip(); ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); ctx.fillStyle = col; ctx.fill(); ctx.restore(); }); } else { ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); ctx.fillStyle = colList[0] || '#78909c'; ctx.fill(); } // 3D highlight overlay ctx.save(); ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); ctx.clip(); const rg = ctx.createRadialGradient(cx2-r*0.38, cy2-r*0.38, r*0.04, cx2, cy2, r); rg.addColorStop(0, 'rgba(255,255,255,0.72)'); rg.addColorStop(0.30,'rgba(255,255,255,0.18)'); rg.addColorStop(0.65,'rgba(0,0,0,0.00)'); rg.addColorStop(1, 'rgba(0,0,0,0.42)'); ctx.fillStyle = rg; ctx.fillRect(cx2-r, cy2-r, r*2, r*2); ctx.restore(); ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI*2); outln(0.8); }; // ── TOPMARKS per IALA ────────────────────────────────────────────────── // PORT mark → square can topmark (cube face, viewed from front) const drawTopmarkCan = (tx, ty, w, col) => { const h = w; // SQUARE — same width and height drawCylinder(tx - w/2, ty - h, w, h, [col]); }; // STBD mark → conical topmark point-up const drawTopmarkCone = (tx, ty, w, col) => { const h = w * 0.9; drawCone(tx, ty - h, ty, w, [col]); }; // Generic cone-up topmark (for other buoys) const drawTopmarkConeSmall = (tx, ty, w, col) => { const h = w * 0.7; drawCone(tx, ty - h, ty, w, [col]); }; // ── DRAW by shape ────────────────────────────────────────────────────── if (isCan) { // ── CAN (Cilíndrica) — 3D cylinder ──────────────────────── drawCylinder(cx - bW/2, bTop, bW, bH, css.length ? css : [c1]); _st(ctx, cx, bTop - eRy, stTop); // PORT topmark = square can (same width as topmark reservation) drawTopmarkCan(cx, stTop, bW * 0.42, c1); } else if (isCone) { // ── CONE (Cónica) — 3D cone ──────────────────────────────── drawCone(cx, bTop, bBot, bW, css.length ? css : [c1]); _st(ctx, cx, bTop, stTop); // STBD topmark = conical point-up drawTopmarkCone(cx, stTop, bW * 0.44, c1); } else if (isSph) { // ── SPHERE (Esférica) — 3D ball ──────────────────────────── const r = bH * 0.44, bcy = bTop + r; drawSphere(cx, bcy, r, css.length ? css : [c1]); _st(ctx, cx, bcy - r, stTop); // Topmark: red sphere (safe water style) const tmR = sz * 0.09; drawSphere(cx, stTop - tmR, tmR, [c1]); } else if (isSpar) { // ── SPAR / ESPEQUE — tall thin cylinder ──────────────────── const sw = bW * 0.24, sparTop = bTop - bH * 0.40; drawCylinder(cx - sw/2, sparTop, sw, bH * 1.40, css.length ? css : [c1]); // Topmark: spar lateral = same rule por catlam if (cl === 1 || cl === 3) drawTopmarkCan (cx, sparTop - 2, sw * 1.4, c1); else if (cl === 2 || cl === 4) drawTopmarkCone(cx, sparTop - 2, sw * 1.4, c1); else drawTopmarkConeSmall(cx, sparTop - 2, sw * 1.4, c1); } else if (isBar) { // ── BARREL (Barril) — fat ellipsoid with stave lines ─────── const rx = bW * 0.52, ry = bH * 0.46, bcy = bTop + ry; // Body fill with sphere-style 3D drawSphere(cx, bcy, Math.min(rx, ry), css.length ? css : [c1]); // Barrel top cap ellipse const tg2 = ctx.createRadialGradient(cx - rx*0.3, bcy-ry, 2, cx, bcy-ry, rx); tg2.addColorStop(0, 'rgba(255,255,255,0.65)'); tg2.addColorStop(1, _lighten3D(c1, 0.2)); ctx.beginPath(); ctx.ellipse(cx, bcy - ry, rx * 0.55, ry * 0.16, 0, 0, Math.PI*2); ctx.fillStyle = tg2; ctx.fill(); outln(0.5); // Barrel bottom cap ellipse ctx.beginPath(); ctx.ellipse(cx, bcy + ry, rx * 0.55, ry * 0.16, 0, 0, Math.PI*2); ctx.fillStyle = _darken3D(c1, 0.35); ctx.fill(); outln(0.5); // Barrel stave ring lines (3 horizontal bands) [0.32, 0.50, 0.68].forEach(t => { ctx.beginPath(); ctx.ellipse(cx, bcy - ry + ry*2*t, rx * 0.52, ry * 0.08, 0, 0, Math.PI*2); ctx.strokeStyle = _darken3D(c1, 0.45); ctx.lineWidth = 0.7; ctx.stroke(); }); _st(ctx, cx, bcy - ry, stTop); if (cl === 1 || cl === 3) drawTopmarkCan (cx, stTop, bW * 0.40, c1); else if (cl === 2 || cl === 4) drawTopmarkCone(cx, stTop, bW * 0.42, c1); else drawTopmarkConeSmall(cx, stTop, bW * 0.44, c1); } else if (isSuper) { // ── SUPER-BUOY / LANBY — wide low platform + tower ───────── const fw = bW * 1.10, fh = bH * 0.55; const fy = bBot - fh; // Lower hull (flat cylinder) drawCylinder(cx - fw/2, fy, fw, fh, css.length ? css : [c1]); // Tower / mast on top const tw = bW * 0.28, th = bH * 0.60; drawCylinder(cx - tw/2, fy - th, tw, th, [_darken3D(c1, 0.10)]); // Light housing at top of tower (small sphere) drawSphere(cx, fy - th - sz*0.07, sz*0.07, [_lighten3D(c1, 0.3)]); // Staff tip _st(ctx, cx, fy - th - sz*0.14, stTop); } else { // ── PILLAR (BOYSHP=4 or unknown) — hull + superstructure ─── const pw = bW * 0.48; // Lower rounded hull (trapezoid wider at bottom) drawCylinder(cx - pw/2, bTop + bH*0.4, pw, bH*0.6, css.length ? css : [c1]); // Upper narrower superstructure const sw2 = pw * 0.7; drawCylinder(cx - sw2/2, bTop, sw2, bH*0.4, [_darken3D(c1, 0.08)]); _st(ctx, cx, bTop, stTop); // Topmark depends on lateral significance (catlam), NOT on hull shape. // PORT (babor, catlam 1/3) → cuadrado (can) // STBD (estribor, catlam 2/4) → cono (cone) if (cl === 1 || cl === 3) drawTopmarkCan (cx, stTop, bW * 0.42, c1); else if (cl === 2 || cl === 4) drawTopmarkCone(cx, stTop, bW * 0.44, c1); else drawTopmarkConeSmall(cx, stTop, bW * 0.50, c1); } // ── Waterline ────────────────────────────────────────────────────────── _wl(ctx, cx, wlY, bW * 0.68); // ── Light flare (S-52 magenta, when buoy has a light) ───────────────── if (opts.hasLight) { _drawLightFlare(ctx, cx, stTop + sz * 0.02, css[0] || '#ffffff'); } return c; } // ── Cardinal buoy (BOYCAR) — black/yellow banded pillar + two-cone topmark ── // N=▲▲ S=▼▼ E=▲▼(base-base) W=▼▲(point-point) // Body banding: N=Blk/Yel S=Yel/Blk E=Blk/Yel/Blk W=Yel/Blk/Yel function _encCardinalCanvas(quadrant, sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const BLK = '#111111', YEL = '#f9a825'; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.20; const bW = sz * 0.44, bH = bBot - bTop; // Body banding const bands = { N:['#111','#f9a825'], NE:['#111','#f9a825'], NW:['#111','#f9a825'], S:['#f9a825','#111'], SE:['#f9a825','#111'], SW:['#f9a825','#111'], E:['#111','#f9a825','#111'], W:['#f9a825','#111','#f9a825'], }[quadrant] || [BLK, YEL]; const bandH = bH / bands.length; bands.forEach((col, i) => { ctx.fillStyle = col; ctx.fillRect(cx - bW/2, bTop + i * bandH, bW, bandH); }); ctx.strokeStyle = '#444'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); // Two-cone topmark const cw = sz * 0.36, ch = sz * 0.14; const t1 = sTop - ch * 0.2; // lower cone top y const t2 = t1 - ch - sz * 0.01; // upper cone top y if (quadrant === 'N' || quadrant === 'NE' || quadrant === 'NW') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'S' || quadrant === 'SE' || quadrant === 'SW') { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'E') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Isolated Danger (BOYISD) — black + red band + two black spheres ──────── function _encIsdCanvas(sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.28; const bW = sz * 0.44, bH = bBot - bTop; ctx.fillStyle = '#111'; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.fillStyle = '#e53935'; ctx.fillRect(cx - bW/2, bTop + bH * 0.35, bW, bH * 0.30); ctx.strokeStyle = '#444'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); const sr = sz * 0.07; _tmSphere(ctx, cx - sr * 1.2, sTop - sr, sr, '#111'); _tmSphere(ctx, cx + sr * 1.2, sTop - sr, sr, '#111'); _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Safe Water (BOYSAW) — red/white vertical stripes + red sphere ────────── function _encSawCanvas(sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.28; const bW = sz * 0.44, bH = bBot - bTop; // Vertical red/white stripes clipped to body rect ctx.save(); ctx.beginPath(); ctx.rect(cx - bW/2, bTop, bW, bH); ctx.clip(); const stripes = 4, sw = bW / stripes; for (let i = 0; i < stripes; i++) { ctx.fillStyle = i % 2 === 0 ? '#e53935' : '#ffffff'; ctx.fillRect(cx - bW/2 + i * sw, bTop, sw, bH); } ctx.restore(); ctx.strokeStyle = '#444'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); _tmSphere(ctx, cx, sTop - sz * 0.07, sz * 0.07, '#e53935'); _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Special Mark (BOYSPP) — yellow body + yellow X topmark ──────────────── function _encSppCanvas(sz = 40) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const wlY = sz * 0.88, bBot = sz * 0.84, bTop = sz * 0.50, sTop = sz * 0.28; const bW = sz * 0.44, bH = bBot - bTop; ctx.fillStyle = '#f9a825'; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.strokeStyle = '#555'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _st(ctx, cx, bTop, sTop); _tmX(ctx, cx, sTop - sz * 0.09, sz * 0.1, '#f9a825'); _wl(ctx, cx, wlY, bW * 0.65); return c; } // ── Special Beacon (BCNSPP) — poste + cuerpo amarillo + tope X ─────────── // Misma estructura que BCNLAT pero: cuerpo amarillo, topmark = cruz X amarilla. function _encBcnSppCanvas(sz = 36) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const col = '#f9a825'; const groundY = sz * 0.92; const poleTopY = sz * 0.04; const poleW = sz * 0.055; const bodyW = sz * 0.28; const bodyTopY = sz * 0.48; const bodyBotY = groundY; const tmW = sz * 0.36; const tmBotY = sz * 0.27; const tmTopY = sz * 0.05; const tmCY = (tmTopY + tmBotY) / 2; const tmR = (tmBotY - tmTopY) * 0.38; // radio del X // Sombra base ctx.beginPath(); ctx.ellipse(cx, groundY, sz * 0.12, sz * 0.020, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill(); // Poste gris const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0); poleGrd.addColorStop(0, '#999'); poleGrd.addColorStop(0.4, '#ddd'); poleGrd.addColorStop(1, '#777'); ctx.fillStyle = poleGrd; ctx.fillRect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY); // Cuerpo amarillo (gradiente 3D) const bodyGrd = ctx.createLinearGradient(cx - bodyW / 2, 0, cx + bodyW / 2, 0); bodyGrd.addColorStop(0, _lighten3D(col, 0.35)); bodyGrd.addColorStop(0.45, col); bodyGrd.addColorStop(1, _darken3D(col, 0.28)); ctx.strokeStyle = 'rgba(0,0,0,0.65)'; ctx.lineWidth = 0.9; ctx.fillStyle = bodyGrd; ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY); ctx.strokeRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY); // Highlight lateral ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY); // Topmark X — contorno oscuro primero, luego amarillo encima ctx.lineCap = 'round'; ctx.lineWidth = sz * 0.10; ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke(); ctx.lineWidth = sz * 0.075; ctx.strokeStyle = col; ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke(); return c; } /* ═══════════════════════════════════════════════════════════════════════════ IALA buoy canvases — exact port of AR ECDIS SVG defs (ui/js/map.js). Each function returns a canvas sized scale × SVG viewBox, with the position dot at the bottom anchor (white halo + black inner). anchor=[0.5, 1.0] in the OL Icon style places that dot on the geo point. ═══════════════════════════════════════════════════════════════════════════ */ const _IALA_S = 0.85; // canvas px per SVG viewBox unit (slightly smaller than native) function _ialaCanvas(svgW, svgH) { const c = document.createElement('canvas'); c.width = Math.round(svgW * _IALA_S); c.height = Math.round(svgH * _IALA_S); return c; } function _ialaPosDot(ctx, cx, cy) { const s = _IALA_S; ctx.beginPath(); ctx.arc(cx*s, cy*s, 3*s, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.beginPath(); ctx.arc(cx*s, cy*s, 1.5*s, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); } function _ialaRoundRect(ctx, x, y, w, h, r) { if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); } else { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } } // ── Lateral port — IALA-A: red can. IALA-B: green can. ──────────────────── function _ialaBoyLatPort(ialaB) { const c = _ialaCanvas(20, 30); const ctx = c.getContext('2d'); const s = _IALA_S; const top = ialaB ? '#33dd33' : '#ee2222'; const body = ialaB ? '#009900' : '#cc0000'; const bot = ialaB ? '#007700' : '#aa0000'; ctx.lineWidth = 1; ctx.strokeStyle = '#111'; // top ellipse ctx.beginPath(); ctx.ellipse(10*s, 7*s, 6*s, 2.5*s, 0, 0, Math.PI*2); ctx.fillStyle = top; ctx.fill(); ctx.stroke(); // body _ialaRoundRect(ctx, 4*s, 7*s, 12*s, 14*s, 2*s); ctx.fillStyle = body; ctx.fill(); ctx.stroke(); // bottom ellipse ctx.beginPath(); ctx.ellipse(10*s, 21*s, 6*s, 2*s, 0, 0, Math.PI*2); ctx.fillStyle = bot; ctx.fill(); ctx.stroke(); _ialaPosDot(ctx, 10, 27); return c; } // ── Lateral starboard — IALA-A: green cone. IALA-B: red cone. ───────────── function _ialaBoyLatStbd(ialaB) { const c = _ialaCanvas(20, 28); const ctx = c.getContext('2d'); const s = _IALA_S; const fill = ialaB ? '#cc0000' : '#009900'; ctx.beginPath(); ctx.moveTo(10*s, 2*s); ctx.lineTo(18*s, 21*s); ctx.lineTo(2*s, 21*s); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke(); _ialaPosDot(ctx, 10, 25); return c; } // ── Cardinal N/S/E/W — pillar + 2-cone topmark ──────────────────────────── function _ialaBoyCar(quadrant) { const isEW = quadrant === 'E' || quadrant === 'W'; const svgH = isEW ? 42 : 40; const c = _ialaCanvas(20, svgH); const ctx = c.getContext('2d'); const s = _IALA_S; const BLK = '#111', YEL = '#ffdd00'; ctx.lineWidth = 0.5*s; ctx.strokeStyle = BLK; const pole = (y1, y2) => { ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(10*s, y1*s); ctx.lineTo(10*s, y2*s); ctx.stroke(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5*s; }; const cone = (apex, baseY, baseLeft, baseRight, fill) => { ctx.beginPath(); ctx.moveTo(apex[0]*s, apex[1]*s); ctx.lineTo(baseRight[0]*s, baseRight[1]*s); ctx.lineTo(baseLeft[0]*s, baseLeft[1]*s); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.stroke(); }; if (quadrant === 'N') { cone([10,0], 7, [6,7], [14,7], BLK); cone([10,8], 15,[6,15], [14,15], BLK); pole(15, 19); ctx.fillStyle = BLK; ctx.fillRect(6*s, 19*s, 8*s, 6*s); ctx.fillStyle = YEL; ctx.fillRect(6*s, 25*s, 8*s, 6*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 19*s, 8*s, 12*s); _ialaPosDot(ctx, 10, 37); } else if (quadrant === 'S') { // 2 cones pointing down ctx.beginPath(); ctx.moveTo(6*s,0); ctx.lineTo(14*s,0); ctx.lineTo(10*s,7*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(6*s,8*s); ctx.lineTo(14*s,8*s); ctx.lineTo(10*s,15*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); pole(15, 19); ctx.fillStyle = YEL; ctx.fillRect(6*s, 19*s, 8*s, 6*s); ctx.fillStyle = BLK; ctx.fillRect(6*s, 25*s, 8*s, 6*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 19*s, 8*s, 12*s); _ialaPosDot(ctx, 10, 37); } else if (quadrant === 'E') { // diamond: cone up + cone down, bases joined ctx.beginPath(); ctx.moveTo(10*s,0); ctx.lineTo(16*s,8*s); ctx.lineTo(10*s,16*s); ctx.lineTo(4*s,8*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); pole(16, 20); ctx.fillStyle = BLK; ctx.fillRect(6*s, 20*s, 8*s, 5*s); ctx.fillStyle = YEL; ctx.fillRect(6*s, 25*s, 8*s, 5*s); ctx.fillStyle = BLK; ctx.fillRect(6*s, 30*s, 8*s, 5*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 20*s, 8*s, 15*s); _ialaPosDot(ctx, 10, 39); } else { // W // bowtie: cone down on top + cone up on bottom, points meet at y=8 ctx.beginPath(); ctx.moveTo(4*s,0); ctx.lineTo(16*s,0); ctx.lineTo(10*s,8*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(4*s,16*s); ctx.lineTo(16*s,16*s); ctx.lineTo(10*s,8*s); ctx.closePath(); ctx.fillStyle = BLK; ctx.fill(); ctx.stroke(); pole(16, 20); ctx.fillStyle = YEL; ctx.fillRect(6*s, 20*s, 8*s, 5*s); ctx.fillStyle = BLK; ctx.fillRect(6*s, 25*s, 8*s, 5*s); ctx.fillStyle = YEL; ctx.fillRect(6*s, 30*s, 8*s, 5*s); ctx.lineWidth = 0.8*s; ctx.strokeRect(6*s, 20*s, 8*s, 15*s); _ialaPosDot(ctx, 10, 39); } return c; } // ── Isolated Danger — BRB body + 2 black spheres on top ──────────────────── function _ialaBoyIsd() { const c = _ialaCanvas(22, 44); const ctx = c.getContext('2d'); const s = _IALA_S; const BLK = '#111', RED = '#cc0000'; ctx.fillStyle = BLK; ctx.beginPath(); ctx.arc(11*s, 4*s, 4*s, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(11*s, 12*s, 4*s, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(11*s, 16*s); ctx.lineTo(11*s, 20*s); ctx.stroke(); ctx.fillStyle = BLK; ctx.fillRect(5*s, 20*s, 12*s, 5*s); ctx.fillStyle = RED; ctx.fillRect(5*s, 25*s, 12*s, 5*s); ctx.fillStyle = BLK; ctx.fillRect(5*s, 30*s, 12*s, 5*s); _ialaPosDot(ctx, 11, 41); return c; } // ── Safe Water — RW horizontal bands + red sphere on top ─────────────────── function _ialaBoySaw() { const c = _ialaCanvas(18, 38); const ctx = c.getContext('2d'); const s = _IALA_S; const RED = '#cc0000'; ctx.fillStyle = RED; ctx.beginPath(); ctx.arc(9*s, 4*s, 4*s, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(9*s, 8*s); ctx.lineTo(9*s, 12*s); ctx.stroke(); ctx.fillStyle = RED; ctx.fillRect(3*s, 12*s, 12*s, 5*s); ctx.fillStyle = '#fff'; ctx.fillRect(3*s, 17*s, 12*s, 5*s); ctx.fillStyle = RED; ctx.fillRect(3*s, 22*s, 12*s, 5*s); ctx.fillStyle = '#fff'; ctx.fillRect(3*s, 27*s, 12*s, 3*s); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5*s; ctx.strokeRect(3*s, 12*s, 12*s, 18*s); _ialaPosDot(ctx, 9, 35); return c; } // ── Special Mark — yellow square + yellow X topmark ─────────────────────── function _ialaBoySpp() { const c = _ialaCanvas(20, 32); const ctx = c.getContext('2d'); const s = _IALA_S; const YEL_DARK = '#cc9900', YEL = '#ffcc00'; ctx.strokeStyle = YEL_DARK; ctx.lineWidth = 2.5*s; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(6*s, 1*s); ctx.lineTo(14*s, 9*s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(14*s, 1*s); ctx.lineTo(6*s, 9*s); ctx.stroke(); ctx.lineCap = 'butt'; ctx.strokeStyle = '#555'; ctx.lineWidth = 1.5*s; ctx.beginPath(); ctx.moveTo(10*s, 9*s); ctx.lineTo(10*s, 13*s); ctx.stroke(); _ialaRoundRect(ctx, 4*s, 13*s, 12*s, 12*s, 2*s); ctx.fillStyle = YEL; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1*s; ctx.stroke(); _ialaPosDot(ctx, 10, 29); return c; } // ── White-light structure symbol (LIGHTS blanco / faro de orilla) ───────────── // Diseño: rectángulo cuerpo blanco con borde negro + estrella de 5 puntas centrada // + lagrima estirada de color purpura que sale del costado superior hacia arriba // a ~20° de la vertical (convención ECDIS / S-52 adaptada). // Para luces de color (rojo/verde) se usa el símbolo de cuerpo de boya en _encLightCanvas. function _ialaLight(colourCode) { const W = 26, H = 46; const c = document.createElement('canvas'); c.width = W; c.height = H; const ctx = c.getContext('2d'); const cx = W / 2; // Sólo se invoca para luces blancas (kind==='flare'); defensivamente mapeamos colores. let col = '#ffffff'; if (colourCode === 3) col = '#dd1111'; else if (colourCode === 4) col = '#00aa00'; else if (colourCode === 6) col = '#ffcc00'; // ── Cuerpo rectangulo (estructura del faro) ─────────────────────────────── const bW = 14, bH = 17; const bTop = H * 0.50; // tope del rectángulo const bBot = bTop + bH; // base del rectángulo ctx.beginPath(); ctx.rect(cx - bW / 2, bTop, bW, bH); ctx.fillStyle = col; // blanco (o color para casos defensivos) ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1.2; ctx.stroke(); // ── Estrella de 5 puntas centrada en el rectángulo ──────────────────────── const starX = cx, starY = bTop + bH / 2; const Ro = 4.5, Ri = 1.85; // radio exterior / interior ctx.beginPath(); for (let i = 0; i < 10; i++) { const ang = (i * Math.PI / 5) - Math.PI / 2; // empieza arriba const rr = (i % 2 === 0) ? Ro : Ri; const px = starX + Math.cos(ang) * rr; const py = starY + Math.sin(ang) * rr; i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); } ctx.closePath(); ctx.fillStyle = (col === '#ffffff') ? '#222' : '#fff'; // contraste ctx.fill(); // ── Lagrima estirada purpura (haz de luz) ───────────────────────────────── // Sale del costado superior-derecho del rectángulo, ~20° de la vertical. const PURP = '#8800cc'; const tearAng = 20 * Math.PI / 180; // 20° desde la vertical const tearLen = 18; // longitud de la lagrima (px) ctx.save(); // Ancla en el extremo superior-derecho del rectángulo ctx.translate(cx + bW / 2 - 1, bTop + 1); ctx.rotate(tearAng); // rotar 20° hacia la derecha // Forma de lagrima: estrecha en la base, ancha a la mitad, punta arriba ctx.beginPath(); ctx.moveTo(0, 0); // base (anclaje) ctx.bezierCurveTo(-3.8, -5, -3.8, -11, 0, -tearLen);// lado izquierdo ctx.bezierCurveTo( 3.8, -11, 3.8, -5, 0, 0); // lado derecho ctx.closePath(); ctx.fillStyle = PURP; ctx.globalAlpha = 0.88; ctx.fill(); ctx.globalAlpha = 1.0; ctx.strokeStyle = 'rgba(90,0,140,0.5)'; ctx.lineWidth = 0.5; ctx.stroke(); ctx.restore(); // ── Punto de anclaje geográfico en la base ──────────────────────────────── ctx.beginPath(); ctx.arc(cx, H - 2, 2.2, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.beginPath(); ctx.arc(cx, H - 2, 1.0, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); return c; } // IHO S-52 "magenta flare" — stylised flash extending up-right from the // light's position. The flare is always magenta (chart convention for "this // is a light symbol"); the actual colour of the emitted light is shown as a // small coloured dot at the apex of the flare. function _drawLightFlare(ctx, cx, y, col) { const MAG = '#ff00aa'; // S-52 magenta const len = 8; // flare length (px) ctx.save(); ctx.translate(cx, y); // Flare: thin triangular wedge at 45° up-right ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(len * 0.95, -len * 0.30); ctx.lineTo(len * 0.30, -len * 0.95); ctx.closePath(); ctx.fillStyle = MAG; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.5; ctx.stroke(); // Position dot at the light's geographic point ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fillStyle = MAG; ctx.fill(); // Coloured tip: shows the actual emitted-light colour if (col && col !== MAG) { ctx.beginPath(); ctx.arc(len * 0.62, -len * 0.62, 1.8, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.4; ctx.stroke(); } ctx.restore(); } // ── Lighted aid (LIGHTS feature) — infer lateral/special from colour + region ─ // S-57 LIGHTS objects often appear without a paired BOYLAT structure. We infer // the host's shape from the light's COLOUR + the cell's IALA region: // IALA-B: green light → port can ; red light → stbd nun // IALA-A: green light → stbd nun ; red light → port can // yellow → SPECIAL ; white/magenta/null → simple flare (no shape inferred) function _encLightCanvas(colours, region, sz = 32) { const raw = colours[0]; const ialaB = (region || 'B') === 'B'; // Decide implied lateral significance from the light's colour let kind = 'flare'; // default if (raw === 4) kind = ialaB ? 'can' : 'cone'; // green else if (raw === 3) kind = ialaB ? 'cone' : 'can'; // red else if (raw === 6) kind = 'special'; // yellow // Pure light feature — render the S-52 radial-cross symbol (ECDIS style). if (kind === 'flare') { return _ialaLight(raw); } // Otherwise the light is hosted on a buoy shape (can/cone/special). const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const col = (!raw || raw === 1) ? '#cc00cc' : _s57css(raw); // Draw a small body silhouette with the light dot perched on its top. const bBot = sz * 0.86, bTop = sz * 0.50, bW = sz * 0.42, bH = bBot - bTop; if (kind === 'can') { ctx.fillStyle = col; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); } else if (kind === 'cone') { ctx.beginPath(); ctx.moveTo(cx, bTop); ctx.lineTo(cx + bW/2, bBot); ctx.lineTo(cx - bW/2, bBot); ctx.closePath(); ctx.fillStyle = col; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 0.8; ctx.stroke(); } else if (kind === 'special') { // Yellow body, X topmark style — borrowed from BOYSPP look ctx.fillStyle = '#f9a825'; ctx.fillRect(cx - bW/2, bTop, bW, bH); ctx.strokeStyle = '#555'; ctx.lineWidth = 0.8; ctx.strokeRect(cx - bW/2, bTop, bW, bH); _tmX(ctx, cx, bTop - sz * 0.06, sz * 0.08, '#f9a825'); } // Waterline _wl(ctx, cx, sz * 0.90, bW * 0.7); // Light flare sits at the top of the body _drawLightFlare(ctx, cx, bTop - sz * 0.10, col); return c; } // ── Lateral beacon (BCNLAT) — poste + cuerpo + HUECO + marca de tope ───────── // Estructura real IALA: palo delgado continuo + cuerpo rectangular en la parte // inferior + tramo de palo libre (hueco visible) + marca de tope separada arriba. // Babor (port, IALA-B verde): marca de tope = cuadrado. // Estribor (stbd, IALA-B rojo): marca de tope = triángulo apuntando arriba. // La marca de tope es levemente más ancha que el cuerpo y queda separada de él. function _encBeaconCanvas(colours, catlam, region, sz = 36) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; let cc = (colours || []).slice(); if (!cc.length && catlam) cc = _ialaLateralColours(catlam, region); const col = cc[0] ? _s57css(cc[0]) : '#78909c'; const isPort = catlam === 1 || catlam === 3; // IALA-B: babor=verde, estribor=rojo // ── Layout: poste continuo + cuerpo (bajo) + hueco + marca de tope (arriba) ─ const groundY = sz * 0.92; // base / nivel del suelo const poleTopY = sz * 0.04; // cima del poste const poleW = sz * 0.055; // grosor del poste delgado // Cuerpo (shaft): rectángulo en la mitad inferior del poste const bodyW = sz * 0.28; const bodyTopY = sz * 0.48; // tope del cuerpo → aquí empieza el hueco visible const bodyBotY = groundY; // Marca de tope: separada del cuerpo, levemente más ancha const tmW = sz * 0.36; // ancho de la marca de tope const tmBotY = sz * 0.27; // base de la marca (límite superior del hueco) const tmTopY = sz * 0.05; // cima de la marca (= ≈ tope del poste) const tmH = tmBotY - tmTopY; // ── Sombra elíptica en la base ──────────────────────────────────────────── ctx.beginPath(); ctx.ellipse(cx, groundY, sz * 0.12, sz * 0.020, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill(); // ── Poste continuo (todo el alto, dibujado primero = detrás) ───────────── const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0); poleGrd.addColorStop(0, '#999'); poleGrd.addColorStop(0.4, '#ddd'); poleGrd.addColorStop(1, '#777'); ctx.beginPath(); ctx.rect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY); ctx.fillStyle = poleGrd; ctx.fill(); // ── Gradiente de color IALA (compartido por cuerpo y marca de tope) ─────── const mkGrd = (w) => { const g = ctx.createLinearGradient(cx - w / 2, 0, cx + w / 2, 0); g.addColorStop(0, _lighten3D(col, 0.35)); g.addColorStop(0.45, col); g.addColorStop(1, _darken3D(col, 0.28)); return g; }; ctx.strokeStyle = 'rgba(0,0,0,0.65)'; ctx.lineWidth = 0.9; // ── Cuerpo (shaft rectangulo, parte inferior) ───────────────────────────── ctx.beginPath(); ctx.rect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY); ctx.fillStyle = mkGrd(bodyW); ctx.fill(); ctx.stroke(); // Highlight lateral izquierdo ctx.beginPath(); ctx.rect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fill(); // ── Marca de tope (separada del cuerpo por el hueco = tramo de poste libre) ─ if (isPort) { // Babor: cuadrado (square daymark) ctx.beginPath(); ctx.rect(cx - tmW / 2, tmTopY, tmW, tmH); ctx.fillStyle = mkGrd(tmW); ctx.fill(); ctx.stroke(); // Highlight lateral ctx.beginPath(); ctx.rect(cx - tmW / 2, tmTopY, tmW * 0.20, tmH); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fill(); } else { // Estribor: triángulo apuntando hacia arriba (triangle daymark) ctx.beginPath(); ctx.moveTo(cx, tmTopY); // vértice superior (apex) ctx.lineTo(cx + tmW / 2, tmBotY); // esquina inferior derecha ctx.lineTo(cx - tmW / 2, tmBotY); // esquina inferior izquierda ctx.closePath(); ctx.fillStyle = mkGrd(tmW); ctx.fill(); ctx.stroke(); } return c; } // ── Range / Leading-mark dayboard — dos triángulos concéntricos ────────────── // Símbolo IALA: triángulo exterior BLANCO + interior NEGRO con lados // estrictamente paralelos (triángulo similar centrado en el baricentro). // Tamaño reducido para no dominar el mapa. function _encRangeDaymarkCanvas(colours, hasLight, sz = 34) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; // Light colour for optional flare const lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff'; // ── Layout ─────────────────────────────────────────────────────────────── const triTop = sz * 0.06; // apex of outer triangle const triBot = sz * 0.94; // base of outer triangle const halfW = sz * 0.44; // half-width at base // ── OUTER triangle — WHITE ─────────────────────────────────────────────── ctx.beginPath(); ctx.moveTo(cx, triTop); ctx.lineTo(cx + halfW, triBot); ctx.lineTo(cx - halfW, triBot); ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = '#555'; ctx.lineWidth = 1.0; ctx.stroke(); // ── INNER triangle — BLACK, lados paralelos al exterior ────────────────── // Escalar desde el baricentro garantiza lados paralelos (triángulo similar). const r = 0.48; // escala del interior const cy = (triTop + 2 * triBot) / 3; // baricentro Y del triángulo exterior const iApexY = cy + r * (triTop - cy); // vértice superior del interior const iBaseY = cy + r * (triBot - cy); // base del interior const iHalf = halfW * r; // media-anchura → lados paralelos ctx.beginPath(); ctx.moveTo(cx, iApexY); ctx.lineTo(cx + iHalf, iBaseY); ctx.lineTo(cx - iHalf, iBaseY); ctx.closePath(); ctx.fillStyle = '#111111'; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 0.6; ctx.stroke(); // ── Light flare al ápice (si la feature tiene luz) ─────────────────────── if (hasLight) { _drawLightFlare(ctx, cx, triTop - sz * 0.02, lightCss); } return c; } // ── Cardinal beacon (BCNCAR) — tripod + two-cone topmark ───────────────────── function _encBcnCardinalCanvas(quadrant, sz = 30) { const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2; const BLK = '#111'; const baseY = sz * 0.90, poleTop = sz * 0.30; ctx.strokeStyle = '#444'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx - sz*0.22, baseY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx + sz*0.22, baseY); ctx.stroke(); ctx.strokeStyle = '#444'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cx, baseY); ctx.lineTo(cx, poleTop); ctx.stroke(); const cw = sz * 0.42, ch = sz * 0.16; const t1 = poleTop - sz * 0.02; const t2 = t1 - ch - sz * 0.01; if (quadrant === 'N') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'S') { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else if (quadrant === 'E') { _tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK); } else { _tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK); } return c; } /* ═══════════════════════════════════════════════════════════════════════════ Multi-cell composition (port from AR ECDIS). - usage_band derived from the 3rd char of the cell id (IHO/NOAA convention): US1GC09M → 1 Overview, US5MIA* → 5 Harbour, etc. - Each band is rendered only inside its own zoom range. Cells with band 0 (unknown) are always rendered. - When a higher-band cell covers ≥40% of the viewport, lower-band cells are hidden — so the harbour cell takes over while the overview stays loaded for panning to less detailed areas. ═══════════════════════════════════════════════════════════════════════════ */ window._chartCells = {}; // cell_id → { bbox: [w,s,e,n], band: int } const _HIDDEN_CELLS = new Set(); // mutated on moveend by _recomputeHiddenCells // Band zoom ranges: [minZoom, maxZoom]. // Band 0 (custom/BARRANQUILLA) siempre visible [0,24]. // Bands 1-4 tienen maxZoom para que celdas overview no compitan con harbour // en zoom alto — sin estos límites, US1GC09M (549 features Overview) aparece // en zoom 11 sobre Miami y su declutter oculta las boyas de mayor resolución. // BARRANQUILLA no se afecta — su cell_id da band=0 por _bandFromCellId. const BAND_ZOOM_RANGES = { 0: [0, 24], // custom / unknown (ej. BARRANQUILLA) → siempre visible 1: [0, 10], // Overview (1:600 000+) — se oculta a zoom >10 2: [3, 12], // General (1:350 000) 3: [6, 15], // Coastal (1:90 000) 4: [8, 18], // Approach (1:25 000) 5: [10, 24], // Harbour (1:7 500) 6: [12, 24], // Berthing (1:2 000) }; function _bandFromCellId(id) { const m = (id || '').match(/^[A-Za-z]{2}([1-6])/); return m ? +m[1] : 0; } // Returns false if this feature's cell should NOT render at the current resolution. function _cellShouldRender(cellId, resolution) { if (!cellId) return true; if (_HIDDEN_CELLS.has(cellId)) return false; const meta = window._chartCells[cellId]; const band = meta ? meta.band : _bandFromCellId(cellId); const zoom = map.getView().getZoomForResolution(resolution); const [zmin, zmax] = BAND_ZOOM_RANGES[band] || BAND_ZOOM_RANGES[0]; return zoom >= zmin && zoom <= zmax; } // On moveend, hide overview cells whose viewport overlap is dominated by a // higher-band cell (≥40% of the viewport area covered by the higher one). // IMPORTANT: only count a higher-band cell as "dominating" if that cell is // actually within its own zoom range at the current zoom. Otherwise band-4 // cells get hidden by a band-5 cell that is itself invisible (zoom < 10), // leaving a gap where NO chart renders at all (the Miami disappear bug). function _recomputeHiddenCells() { _HIDDEN_CELLS.clear(); const cells = window._chartCells; if (!cells || Object.keys(cells).length === 0) return; const ext = map.getView().calculateExtent(map.getSize()); const zoom = map.getView().getZoom() || 0; const [w, s, e, n] = ol.proj.transformExtent(ext, 'EPSG:3857', 'EPSG:4326'); const vArea = (e - w) * (n - s); if (vArea <= 0) return; for (const [cellId, meta] of Object.entries(cells)) { if (!meta.bbox) continue; // Custom / operator-installed cells (band 0) are NEVER hidden by any NOAA // overview cell. They represent the primary operational chart for the port // and must remain visible at all zoom levels. if (meta.band === 0) 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: '500 9px "Inter", "Segoe UI", sans-serif', fill: new ol.style.Fill({ color: '#fff' }), 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 ────────────────────────────────────────────────────── // Usan _encCardinalCanvas (3-D, sz=48) para igualar el tamaño de las boyas // laterales (_encBuoyCanvas sz=52). Las funciones IALA SVG-port producían // un canvas de ~17×35 px que resultaba demasiado pequeño en el mapa. case 'CARDINAL_N': case 'CARDINAL_E': case 'CARDINAL_S': case 'CARDINAL_W': { const q = aidType.split('_')[1]; canvas = _cachedIcon(`enc_car_${q}_${hasLight}`, () => { const cv = _encCardinalCanvas(q, 48); if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, _lightCss); return cv; }); break; } case 'CARDINAL_UNKNOWN': canvas = _cachedIcon(`enc_car_unk_${hasLight}`, () => { const cv = _encCardinalCanvas('N', 48); if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, _lightCss); return cv; }); break; // ── Other buoy types ──────────────────────────────────────────────────── case 'ISOLATED_DANGER': canvas = _cachedIcon(`enc_isd_${hasLight}`, () => { const cv = _encIsdCanvas(48); if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, _lightCss); return cv; }); break; case 'SAFE_WATER': canvas = _cachedIcon(`enc_saw_${hasLight}`, () => { const cv = _encSawCanvas(48); if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, _lightCss); return cv; }); break; case 'SPECIAL': { // BOYSPP (boya flotante) → _encSppCanvas | BCNSPP (baliza fija) → _encBcnSppCanvas if (isBcn) { canvas = _cachedIcon(`bcn_spp_${hasLight}`, () => { const cv = _encBcnSppCanvas(36); if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825'); return cv; }); } else { canvas = _cachedIcon(`boy_spp_${hasLight}`, () => { const cv = _encSppCanvas(48); if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825'); return cv; }); } 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.168 at zoom 6, grows to 0.624 at zoom 14+. (~20 % bigger than // the previous 0.14–0.52 range to improve readability in busy harbours.) // Smooth curve — symbols shrink as user zooms out instead of vanishing. const iconScale = Math.max(0.168, Math.min(0.624, 0.168 + (_zr - 6) * 0.057)); // Position dot — small circle rendered at the exact geographic coordinate. // Anchor is [0.5, 1.0] so the dot sits precisely at the buoy's lat/lon. const dotStyle = new ol.style.Style({ image: new ol.style.Circle({ radius: 2, fill: new ol.style.Fill({ color: 'rgba(10,26,46,0.9)' }), stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 0.8 }), }), }); const iconStyle = 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', }), // Persistent label removed — hover tooltip shows name + light + range. // Avoids visual clutter at busy harbours like BAQ / CTG. }); return [iconStyle, dotStyle]; } // ── 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, aisGhostLayer], 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 ──────────────────────────────────────────────────────────────── // ── Geo helpers ─────────────────────────────────────────────────────────── // Compact nautical lat/lon e.g. 10°31.2'N 74°48.3'W function _fmtLL(lat, lon) { const ld = Math.floor(Math.abs(lat)), lm = (Math.abs(lat) - ld) * 60; const od = Math.floor(Math.abs(lon)), om = (Math.abs(lon) - od) * 60; return `${ld}°${lm.toFixed(1)}'${lat >= 0 ? 'N' : 'S'} ${od}°${om.toFixed(1)}'${lon >= 0 ? 'E' : 'W'}`; } // CPA / TCPA of a moving vessel vs a stationary point (AtoN or own position). // Returns { cpaNM, tcpaMin } or null if vessel is not moving or CPA is behind. function _computeCPA(vesLat, vesLon, sogKt, cogDeg, refLat, refLon) { if (!sogKt || sogKt < 0.1) return null; const mPDLat = 111320; const mPDLon = 111320 * Math.cos(vesLat * Math.PI / 180); const dx = (vesLon - refLon) * mPDLon; // metres east const dy = (vesLat - refLat) * mPDLat; // metres north const spd = sogKt * 0.514444; // m/s const h = cogDeg * Math.PI / 180; const vx = spd * Math.sin(h); const vy = spd * Math.cos(h); const v2 = vx * vx + vy * vy; const tcpaSec = -(dx * vx + dy * vy) / v2; const cpax = dx + tcpaSec * vx; const cpay = dy + tcpaSec * vy; return { cpaNM: Math.sqrt(cpax * cpax + cpay * cpay) / 1852, tcpaMin: tcpaSec / 60, }; } // ── Estimated dimensions by AIS ship type — used when Type 5/24 hasn't arrived yet. // Values are representative midpoints for each class (L×B in metres). function _estimateDims(tipo) { if (tipo >= 80 && tipo < 90) return { length: 180, beam: 28 }; // tanker if (tipo >= 70 && tipo < 80) return { length: 150, beam: 22 }; // cargo if (tipo >= 60 && tipo < 70) return { length: 120, beam: 20 }; // passenger if (tipo >= 40 && tipo < 50) return { length: 40, beam: 10 }; // HSC if (tipo === 52 || tipo === 21) return { length: 35, beam: 10 };// tug if (tipo === 30) return { length: 25, beam: 8 }; // fishing if (tipo === 36 || tipo === 37) return { length: 15, beam: 5 };// sailing return { length: 60, beam: 12 }; // default } // 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 name = feature.get('nombre') || String(feature.get('mmsi') || ''); const sogV = feature.get('sog'); const cogV = feature.get('cog'); const latV = feature.get('lat'); const lonV = feature.get('lon'); const tipNom = feature.get('tipo_nombre') || shipTypeName(feature.get('tipo') || 0); const cpaNM = feature.get('cpa_nm'); const tcpaM = feature.get('tcpa_min'); // Multi-line label — richer content at closer zoom (res < 100 m/px ≈ zoom 12) const detailed = resolution != null && resolution < 100; let labelText = name; const mmsiV = feature.get('mmsi'); const banderaV = feature.get('bandera'); const flagV = banderaV ? flagEmoji(banderaV) : ''; if (detailed) { // L1: nombre [MMSI] const l1 = mmsiV ? `${name} [${mmsiV}]` : name; // L2: bandera (si está disponible) const l2 = flagV ? `${flagV} ${banderaV}` : ''; // L3: tipo · SOG · COG const l3 = `${tipNom} ${sogV != null ? sogV.toFixed(1)+'kn' : '--'} ${cogV != null ? Math.round(cogV)+'°' : '--'}`; // L4: posición náutica const l4 = (latV != null && lonV != null) ? _fmtLL(latV, lonV) : ''; // L5: CPA/TCPA (solo si se acerca) const l5 = (cpaNM != null && tcpaM != null && tcpaM > 0) ? `CPA ${cpaNM.toFixed(2)}NM TCPA ${tcpaM.toFixed(0)}min` : ''; labelText = [l1, l2, l3, l4, l5].filter(Boolean).join('\n'); } else { labelText = (sogV > 0.3 && cogV != null) ? `${name}\n${sogV.toFixed(1)}kn ${Math.round(cogV)}°` : name; } 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') { // S-52 lighthouse symbol: building body + star + magenta flash canvas = _aidIcon(`faro_${stat}`, () => { const base = _ialaLight(1); // white light (code 1) _stampStatus(base, enPos, enMov); return base; }); } else if (tipo === 'FAROL') { // Smaller lighthouse (farol de costa / sector light) canvas = _aidIcon(`farol_${stat}`, () => { const base = _ialaLight(1); _stampStatus(base, enPos, enMov); return base; }); } 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)); } // ── Aid label: nombre / tipo · pos actual / desplazamiento ───────────── const tipoDisp = tipo.replace(/_/g, ' '); const latA = feature.get('lat_actual') ?? feature.get('lat_nominal'); const lonA = feature.get('lon_actual') ?? feature.get('lon_nominal'); const dispM = feature.get('desplazamiento_m') || 0; const posLine = (latA != null && lonA != null) ? `${tipoDisp} ${_fmtLL(latA, lonA)}` : tipoDisp; const dispLine = dispM > 2 ? `Despl: ${dispM.toFixed(0)} m` : ''; const aidLabel = [nombre, posLine, dispLine].filter(Boolean).join('\n'); 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', }), // Sin label persistente: el tooltip de hover ya muestra nombre + características. // Solo se renderiza un indicador rojo si la boya tiene desplazamiento crítico. text: dispM > 15 ? new ol.style.Text({ text: `Despl ${dispM.toFixed(0)} m`, offsetY: 14, font: 'bold 9px Inter, sans-serif', fill: new ol.style.Fill({ color: '#fca5a5' }), stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.55)', width: 2 }), }) : undefined, }); } // ── Actualizar features ──────────────────────────────────────────────────── window.updateVessel = function(data) { let feature = vesselsSource.getFeatureById(data.mmsi); const headingForGeom = data.heading ?? data.cog ?? 0; // Always draw a silhouette polygon. Use reported dimensions (Type 5/24) when // available; fall back to type-estimated dims so all targets show at real scale. const _dims = (data.length && data.beam) ? { length: data.length, beam: data.beam } : _estimateDims(data.tipo || 0); const ringLL = _computeShipPolygon( data.lat, data.lon, headingForGeom, _dims.length, _dims.beam, data.to_bow, data.to_stern, data.to_port, data.to_starboard ); const geometry = new ol.geom.Polygon([ringLL.map(c => ol.proj.fromLonLat(c))]); if (!feature) { feature = new ol.Feature({ geometry }); feature.setId(data.mmsi); vesselsSource.addFeature(feature); } else { feature.setGeometry(geometry); } // CPA/TCPA to nearest monitored AtoN ───────────────────────────────────── let bestCPA = null, bestAtonName = null, bestCPAdist = Infinity; aidsSource.forEachFeature(af => { const aLat = af.get('lat_actual') ?? af.get('lat_nominal'); const aLon = af.get('lon_actual') ?? af.get('lon_nominal'); if (aLat == null || aLon == null) return; const res = _computeCPA(data.lat, data.lon, data.sog || 0, data.cog || 0, aLat, aLon); if (res && res.cpaNM < bestCPAdist) { bestCPAdist = res.cpaNM; bestCPA = res; bestAtonName = af.get('nombre') || af.get('id') || ''; } }); 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, // CPA/TCPA to nearest AtoN cpa_nm: bestCPA ? +bestCPA.cpaNM.toFixed(2) : null, tcpa_min: bestCPA ? +bestCPA.tcpaMin.toFixed(1) : null, cpa_aton: bestAtonName, }); // Pass the function so OL re-evaluates per resolution (silhouette ↔ icon). feature.setStyle(vesselStyle); recordPosition(data.mmsi, data.lat, data.lon); _updateVector(data.mmsi, data.lat, data.lon, data.sog || 0, data.cog || 0); const n = vesselsSource.getFeatures().length; document.getElementById('vessel-count').textContent = n; }; // Sync the AIS-reported ghost marker for an aid. Shown only when the AIS // position differs from nominal by more than ~5 m (avoids ghost shimmer for // boats sitting on the chain swing centre). Style is the same icon as the // nominal marker but drawn with reduced opacity. function _syncAisGhost(data, nominalFeature) { const ghostId = 'ghost_' + data.id; const existing = aisGhostSource.getFeatureById(ghostId); const la = data.lat_actual, lo = data.lon_actual; // Hide ghost when no AIS fix yet, or when fix is essentially on nominal. const HIDE_THRESHOLD_M = 5; const disp = data.desplazamiento_m; if (la == null || lo == null || (typeof disp === 'number' && disp < HIDE_THRESHOLD_M)) { if (existing) aisGhostSource.removeFeature(existing); return; } const coord = ol.proj.fromLonLat([lo, la]); let f = existing; if (!f) { f = new ol.Feature({ geometry: new ol.geom.Point(coord) }); f.setId(ghostId); aisGhostSource.addFeature(f); } else { f.getGeometry().setCoordinates(coord); } // Copy aid props so aidStyle picks the right icon, then apply translucent // version of that style. f.setProperties({ ...data, featureType: 'aid', _ghost: true }); const base = aidStyle(nominalFeature); const img = base.getImage(); const ghostStyle = new ol.style.Style({ image: new ol.style.Icon({ img: img.getImage(), imgSize: [img.getImage().width, img.getImage().height], scale: img.getScale(), anchor: [0.5, 1.0], opacity: 0.45, }), }); f.setStyle(ghostStyle); } window.updateAid = function(data) { let feature = aidsSource.getFeatureById(data.id); // Main feature stays anchored at lat_nominal — "where the buoy should be" // (well-marked, opaque). The AIS-reported position rides on a separate // translucent ghost feature when it differs from nominal. // aid_position WS broadcasts only carry actual lat/lon (no nominal), so // fall back to the feature's existing nominal when nominal isn't in the // payload. If nothing is known yet, bail — we'll redraw on next init/event. const latNom = data.lat_nominal ?? feature?.get('lat_nominal'); const lonNom = data.lon_nominal ?? feature?.get('lon_nominal'); if (latNom == null || lonNom == null) return; const coord = ol.proj.fromLonLat([lonNom, latNom]); if (!feature) { feature = new ol.Feature({ geometry: new ol.geom.Point(coord) }); feature.setId(data.id); aidsSource.addFeature(feature); } else { feature.getGeometry().setCoordinates(coord); } // Merge new fields without losing nominal coords from previous renders. const merged = { ...feature.getProperties(), ...data, lat_nominal: latNom, lon_nominal: lonNom, featureType: 'aid' }; feature.setProperties(merged); feature.setStyle(aidStyle(feature)); // ── Ghost AIS marker ──────────────────────────────────────────────────── // Show a transparent overlay at lat_actual when AIS reported a position // that differs noticeably from nominal. Hidden when actual ≈ nominal so // the operator sees a single opaque marker on stable buoys. _syncAisGhost(merged, 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 = `
AIS TARGET
${p.nombre || 'UNKNOWN'}
MMSI${p.mmsi}
${p.callsign ? `
CALL${p.callsign}
` : ''}
TYPE${p.tipo_nombre || shipTypeName(p.tipo)}
SIZE${dims}
SOG${p.sog} kn
COG${p.cog}°
${p.draught != null ? `
DRAUGHT${p.draught} m
` : ''} ${p.nav_status_name ? `
STATUS${p.nav_status_name}
` : ''}
DEST${p.destino || '--'}
`; } else if (p.featureType === 'aid') { const disp = p.desplazamiento_m ? `${p.desplazamiento_m.toFixed(1)} m` : '--'; const estado = p.en_movimiento ? 'MOVING' : (p.en_posicion !== false ? 'ON POSITION' : 'DISPLACED'); tooltip.innerHTML = `
AID TO NAVIGATION
${p.nombre}
TYPE${p.tipo}
AIS${p.tipo_ais}
DISPL${disp}
STATUS${estado}
`; } else if (p.layer) { // S-57 ENC feature — skip bare land areas (buildings are allowed) if (['LNDARE','LANDMASK','BUAARE','COALNE'].includes(p.layer)) { tooltip.classList.add('hidden'); return; } const src = p.cell ? `S-57 ENC (${p.cell})` : 'S-57 ENC'; const aidType = (p.aid_type || '').replace(/_/g, ' '); const colNames = { 1:'White', 2:'Black', 3:'Red', 4:'Green', 5:'Blue', 6:'Yellow', 7:'Grey', 8:'Brown', 9:'Amber', 10:'Violet', 11:'Orange', 12:'Magenta' }; const colsTxt = (p.colours || []).map(c => colNames[c] || c).join(' / '); // Building tooltip — only name + height, no type header if (p.layer === 'BUISGL') { tooltip.innerHTML = `
${p.name || 'Edificio'}
${p.height_m ? `
HEIGHT${p.height_m} m
` : ''} `; return; } tooltip.innerHTML = `
${src}
${p.name || p.layer}
${aidType ? `
TYPE${aidType}
` : ''} ${colsTxt ? `
COLOUR${colsTxt}
` : ''} ${p.light_desc ? `
LIGHT${p.light_desc}
` : ''} ${p.range_nm ? `
RANGE${p.range_nm} NM
` : ''} ${p.height_m ? `
HEIGHT${p.height_m} m
` : ''} ${p.shape ? `
SHAPE${p.shape}
` : ''} ${p.info ? `
INFO${p.info}
` : ''}
AIS no receiver
`; } }); // ── Click → panel info ───────────────────────────────────────────────────── map.on('click', (evt) => { const feature = map.forEachFeatureAtPixel(evt.pixel, f => f, { hitTolerance: 8 }); if (feature) showInfoPanel(feature.getProperties()); }); function showInfoPanel(p) { const panel = document.getElementById('info-content'); if (p.featureType === 'vessel') { panel.innerHTML = renderVesselInfo(p); } else if (p.featureType === 'aid') { renderAidInfo(p, panel); } else if (p.layer) { renderEncInfo(p, panel); } } // ── Vessel info panel — full AIS data (Type 1/2/3 + 5 + 24) ─────────────── function renderVesselInfo(p) { const lat = p.lat, lon = p.lon; const posStr = (lat != null && lon != null) ? _fmtLL(lat, lon) : '--'; const flagTxt = p.bandera ? `${p.bandera} ${flagEmoji(p.bandera)}` : '--'; const navStatus = p.nav_status_name || (p.nav_status != null ? `Status ${p.nav_status}` : '--'); const navColor = navStatusColor(p.nav_status); const fixTxt = fixTypeName(p.fix_type); return `
AIS VESSEL TARGET
${p.nombre || 'UNKNOWN'}
MMSI ${p.mmsi}${p.callsign ? ' — ' + p.callsign : ''}
POSITION
${posStr}
SOG
${fmtNum(p.sog, 1)} kn
COG
${fmtNum(p.cog, 1)}°
HDG
${fmtNum(p.heading, 0)}°
ROT
${p.rot != null ? fmtNum(p.rot, 1) + '°/min' : '--'}
NAV STATUS
${navStatus}
STATIC DATA — AIS MSG 5
IMO
${p.imo || '--'}
CALLSIGN
${p.callsign || '--'}
FLAG
${flagTxt}
SHIP TYPE
${p.tipo_nombre || shipTypeName(p.tipo)}
LOA
${p.length ? p.length + ' m' : 'est.'}
BEAM
${p.beam ? p.beam + ' m' : 'est.'}
${p.to_bow != null ? `
TO BOW
${p.to_bow} m
TO STERN
${p.to_stern} m
TO PORT
${p.to_port} m
TO STBD
${p.to_starboard} m
` : ''}
DRAUGHT
${p.draught != null ? p.draught + ' m' : '--'}
FIX TYPE
${fixTxt}
CPA / TCPA
${(p.cpa_nm != null && p.tcpa_min != null) ? `
CPA
${p.cpa_nm.toFixed(2)} NM
TCPA
${p.tcpa_min < 0 ? 'passed' : p.tcpa_min.toFixed(0) + ' min'}
${p.cpa_aton ? `
REF ATON
${p.cpa_aton}
` : ''} ` : `
No AtoN in range or vessel not moving
`}
VOYAGE
DESTINATION
${p.destino || '--'}
ETA
${p.eta || '--'}
LAST SIGNAL
${formatUTC(p.timestamp)}
${_ageStr(p.timestamp)}
`; } function fmtNum(v, d) { return v == null || isNaN(v) ? '--' : Number(v).toFixed(d); } // MMSI MID → ISO-2 country code (subset of common flags). Best-effort. function flagEmoji(code) { const map = { US:'🇺🇸', PA:'🇵🇦', BS:'🇧🇸', LR:'🇱🇷', MH:'🇲🇭', MT:'🇲🇹', CY:'🇨🇾', SG:'🇸🇬', HK:'🇭🇰', GR:'🇬🇷', NO:'🇳🇴', GB:'🇬🇧', NL:'🇳🇱', DE:'🇩🇪', FR:'🇫🇷', IT:'🇮🇹', ES:'🇪🇸', JP:'🇯🇵', KR:'🇰🇷', CN:'🇨🇳', BR:'🇧🇷', MX:'🇲🇽', CO:'🇨🇴', VE:'🇻🇪', AR:'🇦🇷', CL:'🇨🇱', PE:'🇵🇪', EC:'🇪🇨', }; return map[code] || ''; } // Color hint for nav status — green when steaming, amber moored/anchor, red restricted function navStatusColor(s) { if (s == null) return 'var(--text)'; if (s === 0 || s === 8) return '#4ade80'; // under way if (s === 1 || s === 5) return '#fbbf24'; // anchor / moored if (s === 6) return '#dc2626'; // aground if (s === 7) return '#06b6d4'; // fishing if (s >= 2 && s <= 4) return '#fb923c'; // restricted if (s === 14) return '#dc2626'; // SART/EPIRB distress return 'var(--text)'; } function fixTypeName(t) { const m = {1:'GPS', 2:'GLONASS', 3:'GPS+GLONASS', 4:'Loran-C', 5:'Chayka', 6:'INS', 7:'Surveyed', 8:'Galileo'}; return m[t] || (t == null ? '--' : `Type ${t}`); } // Map S-57 layer codes to Aid.tipo enum (best effort; operator can refine). function _s57LayerToAidTipo(layer, aidType) { const L = (layer || '').toUpperCase(); if (L === 'BOYLAT') return 'BOYA_LATERAL'; if (L === 'BOYCAR') return 'BOYA_CARDINAL'; if (L === 'BOYISD') return 'BOYA_PELIGRO'; if (L === 'BOYSAW') return 'BOYA_AGUAS_SEGURAS'; if (L === 'BOYSPP') return 'BOYA_ESPECIAL'; if (L.startsWith('BCN')) return 'BALIZA'; if (L === 'LIGHTS') return 'FAROL'; if (L === 'DAYMAR') return 'BALIZA'; return 'BOYA_ESPECIAL'; } // Stable fingerprint for a S-57 feature within a cell. LNAM would be ideal // but the chart cache doesn't expose it, so we hash layer + position rounded // to 6 decimals (~11 cm). Stable across reloads and minor ENC re-edits that // don't move the feature. function _chartFeatureId(layer, lon, lat) { return `${(layer || 'UNK').toUpperCase()}_${lat.toFixed(6)}_${lon.toFixed(6)}`; } async function renderEncInfo(p, panel) { const geom = p.geometry; const coord = geom ? ol.proj.toLonLat(geom.flatCoordinates || [0,0]) : null; const aidType = (p.aid_type || '').replace(/_/g, ' '); const colNames = { 1:'White', 2:'Black', 3:'Red', 4:'Green', 5:'Blue', 6:'Yellow', 7:'Grey', 8:'Brown', 9:'Amber', 10:'Violet', 11:'Orange', 12:'Magenta' }; const colsTxt = (p.colours || []).map(c => colNames[c] || c).join(' / '); // Render S-57 panel synchronously (no flicker if /aids lookup is slow). const baseHtml = `
S-57 ENC CHART
${p.name || p.layer}
${p.cell || ''} — ${p.layer}${p.cell_region ? ' — IALA-' + p.cell_region : ''}
${coord ? `
OFFICIAL POSITION (S-57)
${coord[1].toFixed(5)} ${coord[1] >= 0 ? 'N' : 'S'}   ${Math.abs(coord[0]).toFixed(5)} ${coord[0] >= 0 ? 'E' : 'W'}
` : ''}
${aidType ? `
TYPE
${aidType}
` : ''} ${colsTxt ? `
COLOUR
${colsTxt}
` : ''} ${p.light_desc ? `
LIGHT
${p.light_desc}
` : ''} ${p.range_nm ? `
RANGE
${p.range_nm} NM
${p.height_m ? `
HEIGHT
${p.height_m} m
` : ''}
` : ''} ${p.shape ? `
SHAPE
${p.shape}
` : ''} ${p.info ? `
INFO
${p.info}
` : ''}
AIS DATA
Configure as monitored aid below to link an AIS MMSI and start receiving Type 21 updates.
SOURCE
S-57 OFFICIAL CHART
`; panel.innerHTML = baseHtml; // Cannot link without cell + position. Bail silently. if (!coord || !p.cell) return; const [lon, lat] = coord; const cellId = p.cell; const featureId = _chartFeatureId(p.layer, lon, lat); // Render the SAVE button up-front — but ONLY for superadmins. Promoting a // S-57 chart feature to a monitored Aid is a config-level action that // creates an immutable UUID and should not be available to regular admins // or unauthenticated viewers. The async lookup below still runs (so an // existing aid still upgrades the panel) regardless of role. const slot = panel.querySelector('#enc-aid-link-slot'); const isSuper = !!window.Auth?.isSuperAdmin?.(); if (slot && isSuper) { slot.innerHTML = `
Crea un registro editable enlazado a este feature de la carta. Luego podrás asignar MMSI / lámpara / responsable.
`; } let existingAid = null; try { const r = await fetch(`/aids/by-chart-feature?cell_id=${encodeURIComponent(cellId)}&feature_id=${encodeURIComponent(featureId)}`); if (r.ok) existingAid = await r.json(); else if (r.status !== 404) throw new Error(`Lookup failed (${r.status})`); } catch (e) { console.warn('[enc] Aid lookup error:', e); // Keep the SAVE button visible — user can still try to create. return; } if (existingAid) { // An Aid already tracks this S-57 feature — replace the panel with the // full Aid info so the operator sees MMSI / lamp / alerts / EDIT button. if (window.renderAidInfo) { window.renderAidInfo({ ...existingAid, featureType: 'aid' }, panel); } return; } // Wire the button click handler (button HTML already rendered above). document.getElementById('btn-promote-s57')?.addEventListener('click', async () => { const btn = document.getElementById('btn-promote-s57'); if (!window.Auth?.isAdmin?.()) { window.Modal?.openLogin?.(null); return; } btn.disabled = true; btn.textContent = 'GUARDANDO...'; try { const r = await fetch('/aids/from-chart-feature', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.Auth.token()}`, }, body: JSON.stringify({ cell_id: cellId, chart_feature_id: featureId, lat_nominal: lat, lon_nominal: lon, nombre: p.name || `${p.layer} ${featureId}`, tipo: _s57LayerToAidTipo(p.layer, p.aid_type), categoria: 'FLOTANTE', radio_borneo_m: 10.0, }), }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || `HTTP ${r.status}`); const newAid = await r.json(); // Add the new feature to the aids layer immediately window.updateAid?.(newAid); // Re-render panel as a full Aid panel (with EDIT AID DATA button) window.renderAidInfo?.({ ...newAid, featureType: 'aid' }, panel); } catch (e) { btn.disabled = false; btn.textContent = 'GUARDAR COMO AYUDA MONITOREADA'; alert('No se pudo crear la ayuda: ' + e.message); } }); } // ── Lamp assignment from the right panel ────────────────────────────────── window.assignLamp = async function(aidId, lampId) { try { const r = await fetch(`/aids/${aidId}/lamp`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lamp_id: lampId || null }), }); if (!r.ok) throw new Error((await r.json()).detail || 'Failed'); const updated = await r.json(); // Re-render the panel with fresh data so the warn/alarm row appears renderAidInfo(updated, document.getElementById('info-content')); } catch (e) { alert('Could not assign lamp: ' + e.message); } }; // ── Manual recording toggle ─────────────────────────────────────────────── const _manualRecs = new Set(); // mmsis currently being manually recorded window.toggleManualRec = async function(mmsi) { const btn = document.getElementById('btn-rec-vessel'); const isRec = _manualRecs.has(mmsi); try { const url = isRec ? `/recordings/stop/${mmsi}?aid_id=MANUAL` : `/recordings/start/${mmsi}?aid_id=MANUAL`; const r = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${window.Auth?.token()}` }, }); if (!r.ok) throw new Error((await r.json()).detail); if (isRec) { _manualRecs.delete(mmsi); if (btn) { btn.textContent = '⏺ START RECORDING'; btn.style.background = 'var(--red)'; } } else { _manualRecs.add(mmsi); if (btn) { btn.textContent = '⏹ STOP RECORDING'; btn.style.background = '#7c2d12'; } } } catch(e) { alert('Recording error: ' + e.message); } }; window.refreshInfoPanel = function(p) { renderAidInfo(p, document.getElementById('info-content')); }; function renderAidInfo(p, panel) { panel.dataset.mmsi = p.mmsi || ''; const disp = p.desplazamiento_m || 0; const dispPct = Math.min(100, (disp / 20) * 100); const dispColor = disp > 15 ? '#e53935' : disp > 8 ? '#f59e0b' : '#1db954'; const pillClass = p.en_movimiento ? 'pill-danger' : (p.en_posicion !== false ? 'pill-ok' : 'pill-warn'); const dotClass = p.en_movimiento ? 'dot-danger' : (p.en_posicion !== false ? 'dot-ok' : 'dot-warn'); const statusTxt = p.en_movimiento ? 'CONTINUOUS MOVEMENT' : (p.en_posicion !== false ? 'ON POSITION' : 'DISPLACED'); const posActual = (p.lat_actual && p.lon_actual) ? `
CURRENT POSITION (AIS)
${p.lat_actual.toFixed(5)} N   ${p.lon_actual.toFixed(5)} W
DISPLACEMENT — ${disp.toFixed(1)} m
` : ''; // Lamp section — assignment + computed battery thresholds const lamps = window._lampCache || []; const currentLamp = lamps.find(l => l.id === p.lamp_id); const lampOptions = ` ${lamps.map(l => ``).join('')}`; const lampBlock = `
LAMP & BATTERY THRESHOLDS
${currentLamp ? `
V min
${currentLamp.voltage_min} V
V max
${currentLamp.voltage_max} V
WARN
${currentLamp.warn_v} V
ALARM
${currentLamp.alarm_v} V
` : `
⚠ No lamp configured — battery alerts use global defaults from Settings.
`} `; // Pull live ATON telemetry if available const aton = window.atonData?.[p.mmsi] || {}; const hasAton = Object.keys(aton).length > 0; // ATON block: SIEMPRE se renderiza para que TODA ayuda pueda configurarse // como referencia (luz, alcance, telemetría esperada). // Si hay live data → se muestra; sino → "Waiting for live telemetry…". const atonTelem = hasAton ? `
LIVE TELEMETRY ${aton.last_update ? aton.last_update.slice(11,19)+' UTC' : ''}
${aton.virtual ? '
VIRTUAL ATON
' : ''}
` : '
Waiting for live telemetry…
'; const atonBlock = `
ATON TRANSPONDER
LIGHT
${p.caracteristica_luz || '--'}
RANGE
${p.alcance_nm != null ? p.alcance_nm + ' NM' : '--'}
${atonTelem} `; panel.innerHTML = `
AID TO NAVIGATION
${p.nombre}
${p.tipo} — ${p.categoria}
${statusTxt}
NOMINAL POSITION (OFFICIAL)
${p.lat_nominal?.toFixed(5)} N   ${p.lon_nominal?.toFixed(5)} W
${posActual}
AIS TYPE
${p.tipo_ais}
SWING RADIUS
${p.radio_borneo_m || 0} m
${p.mmsi ? `
MMSI
${p.mmsi}
` : ''} ${lampBlock}
PORT RESPONSIBLE
${p.puerto_responsable || '--'}
COMPANY
${p.empresa_responsable || '--'}
${p.observaciones ? `
OBSERVATIONS
${p.observaciones}
` : ''} ${atonBlock} ${p.mmsi ? `
BATTERY HISTORY
` : ''} `; document.getElementById('btn-edit-aid')?.addEventListener('click', () => { if (window.Modal) window.Modal.openEdit(p); }); // Load battery chart if buoy has AIS if (p.mmsi) { const lamp = (window._lampCache || []).find(l => l.id === p.lamp_id); const warnV = lamp?.warn_v ?? null; const almV = lamp?.alarm_v ?? null; _loadBatteryChart(p.mmsi, warnV, almV, 1, p.lat_nominal, p.lon_nominal, p.caracteristica_luz); document.querySelectorAll('.batt-rb').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.batt-rb').forEach(b => b.classList.remove('active')); this.classList.add('active'); _loadBatteryChart(p.mmsi, warnV, almV, Number(this.dataset.range), p.lat_nominal, p.lon_nominal, p.caracteristica_luz); }); }); } } // ── Sunrise/sunset (NOAA simplified, accurate ±2 min, no external dependency) function _sunTimes(lat, lon, dateUTC) { var D = Math.floor(dateUTC / 86400000); var JD = D + 2440587.5; var n = JD - 2451545.0; var L = ((280.46 + 0.9856474 * n) % 360 + 360) % 360; var g = ((357.528 + 0.9856003 * n) % 360 + 360) % 360; var lam = L + 1.915 * Math.sin(g * Math.PI/180) + 0.020 * Math.sin(2*g*Math.PI/180); var eps = 23.439 - 0.0000004 * n; var sinDec = Math.sin(eps * Math.PI/180) * Math.sin(lam * Math.PI/180); var dec = Math.asin(sinDec); var latR = lat * Math.PI / 180; var cosHA = (Math.cos((-0.833)*Math.PI/180) - Math.sin(latR)*sinDec) / (Math.cos(latR) * Math.cos(dec)); if (Math.abs(cosHA) > 1) return null; // polar day/night var HA = Math.acos(cosHA) * 180 / Math.PI; var B = 360/365 * (n - 81) * Math.PI/180; var EoT = 9.87*Math.sin(2*B) - 7.53*Math.cos(B) - 1.5*Math.sin(B); // minutes var noon = 720 - 4*lon - EoT; // solar noon in minutes from midnight UTC var dayMs = D * 86400000; return { rise: new Date(dayMs + (noon - HA*4) * 60000), set: new Date(dayMs + (noon + HA*4) * 60000), }; } // Build day/night band list for a time range (one entry per solar transition) function _solarBands(lat, lon, tMin, tMax) { var bands = []; var d = new Date(tMin); d.setUTCHours(0,0,0,0); while (d.getTime() <= tMax) { var st = _sunTimes(lat, lon, d.getTime()); if (st) { bands.push({ t: Math.max(tMin, st.rise.getTime()), type: 'day' }); bands.push({ t: Math.max(tMin, st.set.getTime()), type: 'night'}); } d.setUTCDate(d.getUTCDate() + 1); } // Determine opening phase var firstSt = _sunTimes(lat, lon, tMin); var openNight = firstSt ? (tMin < firstSt.rise.getTime() || tMin > firstSt.set.getTime()) : true; bands.sort((a,b) => a.t - b.t); return { bands, openNight }; } // ── Flash character parser → power consumption index (base = Fl 5s = 1.0) function _parseFlashChar(char) { if (!char) return { label: '—', index: 1.0 }; var c = char.trim().toUpperCase(); if (/^F\b/.test(c) && !c.startsWith('FL')) return { label: 'Fixed (continuous, max consumption)', index: 12.0 }; if (/^OC|^OCC/.test(c)) return { label: 'Occulting (~75% on)', index: 5.0 }; if (/^ISO/.test(c)) return { label: 'Isophase (50% on)', index: 3.0 }; if (/^VQ/.test(c)) return { label: 'Very Quick ~120/min', index: 2.5 }; if (/^IQ|^Q/.test(c)) return { label: 'Quick ~60/min', index: 1.5 }; // Fl(N) Xs var grp = c.match(/FL\((\d+)\)\s*(\d+(?:\.\d+)?)S/); if (grp) { var f = parseInt(grp[1]), p2 = parseFloat(grp[2]); var rate = f / p2 * 60; return { label: `Fl(${f}) ${p2}s — ${rate.toFixed(1)} fl/min`, index: Math.max(0.3, Math.min(4.0, rate / 12)) }; } // Fl Xs var fl = c.match(/FL(?:\(1\))?\s*(\d+(?:\.\d+)?)S/); if (fl) { var per = parseFloat(fl[1]); var rate2 = 60 / per; return { label: `Fl ${per}s — ${rate2.toFixed(1)} fl/min`, index: Math.max(0.2, Math.min(2.0, rate2 / 12)) }; } return { label: char, index: 1.0 }; } // ── Battery history chart ───────────────────────────────────────────────── async function _loadBatteryChart(mmsi, warnV, almV, days, lat, lon, flashChar) { var svgEl = document.getElementById('batt-chart-svg'); var statsEl = document.getElementById('batt-chart-stats'); if (!svgEl) return; svgEl.innerHTML = '
Loading…
'; if (statsEl) statsEl.innerHTML = ''; try { var from = new Date(Date.now() - days * 86400000).toISOString(); var r = await fetch(`${window.API}/tracks/atons/${mmsi}?from=${from}&limit=8000`, { headers: Auth.session ? { Authorization: 'Bearer ' + Auth.session.token } : {} }); if (!r.ok) throw new Error('API error'); var rows = await r.json(); var pts = rows.filter(function(d){ return d.voltage_v != null; }) .map(function(d){ return { t: new Date(d.ts).getTime(), v: d.voltage_v }; }); if (pts.length < 2) { svgEl.innerHTML = '
Not enough data for this period.
'; return; } // Solar bands for day/night shading var solar = (lat != null && lon != null) ? _solarBands(lat, lon, pts[0].t, pts[pts.length-1].t) : null; // Segment data into day/night for separate rate calculation var chargeRates = [], dischargeRates = []; if (solar) { var bands = solar.bands; var classify = function(ts) { var type = solar.openNight ? 'night' : 'day'; for (var i = 0; i < bands.length; i++) { if (ts >= bands[i].t) type = bands[i].type; else break; } return type; }; for (var i = 1; i < pts.length; i++) { var prev = pts[i-1], cur = pts[i]; var midT = (prev.t + cur.t) / 2; var dV = cur.v - prev.v; var dH = (cur.t - prev.t) / 3600000; if (dH < 0.001) continue; var rate = dV / dH; if (classify(midT) === 'day') chargeRates.push(rate); else dischargeRates.push(rate); } } var avgRate = function(arr) { if (!arr.length) return null; return arr.reduce(function(s,v){return s+v;},0) / arr.length; }; var chargeRate = avgRate(chargeRates); var dischargeRate = avgRate(dischargeRates); svgEl.innerHTML = _battSvg(pts, warnV, almV, solar); // ── Stats panel ─────────────────────────────────────────────────────── if (statsEl) { var latest = pts[pts.length-1].v; var flash = _parseFlashChar(flashChar); var fmtRate = function(v) { if (v === null) return '—'; return (v >= 0 ? '+' : '') + v.toFixed(3) + ' V/h'; }; var etaHtml = ''; if (almV != null && dischargeRate !== null && dischargeRate < 0) { var nightsLeft = (latest - almV) / Math.abs(dischargeRate) / 12; // approx 12h night if (nightsLeft > 0) { var daysLeft = Math.floor(nightsLeft); etaHtml = `Nights to alarm: ${daysLeft}`; } } var chargeHtml = chargeRate !== null ? `☀ Charge: ${fmtRate(chargeRate)}` : ''; var dischHtml = dischargeRate !== null ? `☾ Discharge: ${fmtRate(dischargeRate)}` : ''; var flashHtml = ` ⚡ Consumption index: ${flash.index.toFixed(1)}× ${flash.label} `; statsEl.innerHTML = `
Now: ${latest.toFixed(2)} V ${chargeHtml} ${dischHtml} ${etaHtml} ${flashHtml}
`; } } catch(e) { if (svgEl) svgEl.innerHTML = '
Error loading data.
'; } } function _battSvg(pts, warnV, almV, solar) { var W = 280, H = 100, PAD = { t: 6, r: 28, b: 18, l: 34 }; var pw = W - PAD.l - PAD.r, ph = H - PAD.t - PAD.b; var tMin = pts[0].t, tMax = pts[pts.length-1].t; var vVals = pts.map(function(p){return p.v;}); var vMin = Math.min.apply(null, vVals.concat(almV != null ? [almV] : [])) * 0.985; var vMax = Math.max.apply(null, vVals.concat(warnV != null ? [warnV] : [])) * 1.008; if (vMax <= vMin) vMax = vMin + 0.5; var tx = function(t) { return PAD.l + (t - tMin) / (tMax - tMin) * pw; }; var ty = function(v) { return PAD.t + ph - (v - vMin) / (vMax - vMin) * ph; }; // ── Day/night background bands ────────────────────────────────────────── var bands = ''; if (solar) { var events = [{ t: tMin, type: solar.openNight ? 'night' : 'day' }] .concat(solar.bands) .concat([{ t: tMax, type: '_end' }]); for (var i = 0; i < events.length - 1; i++) { var ev = events[i], next = events[i+1]; var x1 = tx(Math.max(tMin, ev.t)), x2 = tx(Math.min(tMax, next.t)); if (x2 <= x1) continue; var fill = ev.type === 'day' ? 'rgba(255,220,80,0.06)' : 'rgba(10,20,60,0.45)'; bands += ``; } } // ── Threshold lines ───────────────────────────────────────────────────── var warnLine = '', almLine = ''; if (warnV != null && warnV >= vMin && warnV <= vMax) { var wy = ty(warnV).toFixed(1); warnLine = ` ${warnV.toFixed(2)}V`; } if (almV != null && almV >= vMin && almV <= vMax) { var ay = ty(almV).toFixed(1); almLine = ` ${almV.toFixed(2)}V`; } // ── Data line ─────────────────────────────────────────────────────────── var latest = pts[pts.length-1].v; var lineColor = almV != null && latest <= almV ? '#f44336' : warnV != null && latest <= warnV ? '#ffb300' : '#00bfff'; var line = pts.map(function(p){ return tx(p.t).toFixed(1)+','+ty(p.v).toFixed(1); }).join(' '); // Current value dot + label var curY = ty(latest).toFixed(1); var curDot = ``; // ── Time axis labels ──────────────────────────────────────────────────── var fmt = function(ts) { var d = new Date(ts); return (d.getDate()+'/'+(d.getMonth()+1)+' ' +String(d.getUTCHours()).padStart(2,'0')+':'+String(d.getUTCMinutes()).padStart(2,'0')); }; var timeAxis = ` ${fmt(tMin)} ${fmt(tMax)}`; // ── Legend: ☀ / ☾ ─────────────────────────────────────────────────────── var legend = solar ? ` ☀ day ☾ night` : ''; return ` ${bands} ${warnLine}${almLine} ${curDot} ${legend} ${timeAxis} `; } window.toggleAtonField = function(field, show) { const el = document.getElementById('af-' + field); if (el) el.closest('label').style.display = show ? '' : 'none'; }; function shipTypeName(tipo) { if (tipo >= 60 && tipo < 70) return 'PASSENGER'; if (tipo >= 70 && tipo < 80) return 'CARGO'; if (tipo >= 80 && tipo < 90) return 'TANKER'; if (tipo === 52 || tipo === 21) return 'TUG'; if (tipo === 30) return 'FISHING'; return `TYPE ${tipo}`; } function formatUTC(ts) { if (!ts) return '--'; return new Date(ts).toUTCString().replace('GMT', 'UTC'); } function _ageStr(ts) { if (!ts) return ''; const sec = Math.round((Date.now() - new Date(ts).getTime()) / 1000); if (sec < 60) return `${sec}s ago`; if (sec < 3600) return `${Math.round(sec / 60)} min ago`; return `${Math.round(sec / 3600)} h ago`; } // ── Coordenadas en barra inferior ───────────────────────────────────────── map.on('pointermove', (evt) => { const coord = ol.proj.toLonLat(evt.coordinate); const lat = coord[1].toFixed(4); const lon = coord[0].toFixed(4); document.getElementById('map-coords').textContent = `LAT ${lat > 0 ? '+' : ''}${lat} LON ${lon > 0 ? '+' : ''}${lon}`; }); // ── Áreas de operación ───────────────────────────────────────────────────── const AREAS = { barranquilla: { label: 'Barranquilla / Bocas de Ceniza', lat: 10.9878, lon: -74.8040, zoom: 13, // Cuando haya cartas S-57: celdas CO4D0B00, CO4D0C00 charts: ['CO4D0B00', 'CO4D0C00'], }, cartagena: { label: 'Cartagena', lat: 10.4236, lon: -75.5478, zoom: 13, charts: ['CO4D0A00'], }, santamarta: { label: 'Santa Marta', lat: 11.2408, lon: -74.2041, zoom: 13, charts: ['CO4D0D00'], }, buenaventura: { label: 'Buenaventura', lat: 3.8800, lon: -77.0300, zoom: 13, charts: ['CO4P0A00'], }, colombia: { label: 'Colombia — Overview', lat: 7.5000, lon: -76.0000, zoom: 6, charts: [], }, miami: { label: 'Miami (Test)', lat: 25.7743, lon: -80.1937, zoom: 11, charts: ['US5FL12M'], // celda NOAA ENC Miami }, }; function flyToArea(areaKey) { const area = AREAS[areaKey]; if (!area) return; map.getView().animate({ center: ol.proj.fromLonLat([area.lon, area.lat]), zoom: area.zoom, duration: 1200, easing: ol.easing.easeOut, }); // Mostrar en barra inferior qué área está activa document.getElementById('map-coords').textContent = `AREA: ${area.label.toUpperCase()} | LAT ${area.lat.toFixed(4)} LON ${area.lon.toFixed(4)}`; // TODO: cuando haya cartas S-57, activar celdas: area.charts console.info('Chart cells for this area:', area.charts); } window.flyToCoords = function(lon, lat, zoom) { map.getView().animate({ center: ol.proj.fromLonLat([lon, lat]), zoom: zoom, duration: 1200, easing: ol.easing.easeOut, }); document.getElementById('map-coords').textContent = `LAT ${lat.toFixed(4)} LON ${lon.toFixed(4)}`; }; // ── NOAA nearby charts panel ─────────────────────────────────────────────── let _cnpDismissed = false; document.querySelector('.cnp-close')?.addEventListener('click', (e) => { e.stopPropagation(); _cnpDismissed = true; document.getElementById('chart-nearby-panel').classList.add('hidden'); }); window._cnpClose = function() { _cnpDismissed = true; document.getElementById('chart-nearby-panel').classList.add('hidden'); }; let _chartQueryTimer = null; map.getView().on('change', () => { clearTimeout(_chartQueryTimer); _chartQueryTimer = setTimeout(queryNearbyCharts, 1500); }); async function queryNearbyCharts() { const ext = ol.proj.transformExtent(map.getView().calculateExtent(), 'EPSG:3857', 'EPSG:4326'); const [west, south, east, north] = ext; const panel = document.getElementById('chart-nearby-panel'); const body = document.getElementById('cnp-body'); try { const r = await fetch(`/charts/noaa-nearby?west=${west}&south=${south}&east=${east}&north=${north}`); const cells = await r.json(); if (!cells.length) { // Not in NOAA coverage (e.g. Colombia) — suggest UKHO/DIMAR body.innerHTML = `
No free NOAA charts for this area.
`; if (!_cnpDismissed) panel.classList.remove('hidden'); return; } // Show up to 8 cells, prefer higher scale (more detail) const top = cells.slice(0, 8); body.innerHTML = top.map(c => `
${c.id} ${c.name || c.id} 1:${(c.scale||0).toLocaleString()} ${c.installed ? `INSTALLED` : ``}
`).join('') + `
NOAA free ENCs · ${cells.length} cell(s) in view
`; if (!_cnpDismissed) panel.classList.remove('hidden'); } catch(e) { panel.classList.add('hidden'); } } // ── Language toggle ──────────────────────────────────────────────────────── const LANG = { en: { 'nav.settings':'SETTINGS','nav.charts':'CHARTS','nav.reports':'REPORTS','nav.users':'USERS', 'dd.datasource':'Data Source / AIS','dd.station':'Station & Antenna','dd.alerts':'Alert Parameters', 'dd.installed':'Installed Charts','dd.install':'Install Chart Cell (.000)','dd.noaa':'Download NOAA Free Charts', 'dd.recordings':'Vessel Recordings (VDR)','dd.history':'AIS Track History','dd.export':'Export Data (CSV)', 'dd.manage':'Manage Users','dd.create':'Create User', 'tb.port':'PORT','tb.port.ph':'Search worldwide port...', 'tb.display':'DISPLAY', 'tb.all':'ALL','tb.vessels':'VESSELS','tb.aids':'AIDS','tb.alerts':'ALERTS', 'tb.layer':'LAYER','tb.world':'WORLD','tb.seamark':'SEAMARK', 'tb.land':'LAND','tb.bathy':'BATHY','tb.hazards':'HAZARDS','tb.zones':'ZONES', 'tb.night':'NIGHT','tb.day':'DAY', 'stat.vessels':'VESSELS','stat.aids':'AIDS','stat.link':'LINK', 'sector.info':'OBJECT INFO','sector.events':'EVENTS & ALERTS', 'no.sel':'SELECT OBJECT ON CHART', 'btn.edit':'EDIT AID DATA','btn.track':'TRACK & PROJECT HEADING', }, es: { 'nav.settings':'AJUSTES','nav.charts':'CARTAS','nav.reports':'REPORTES','nav.users':'USUARIOS', 'dd.datasource':'Fuente de datos / AIS','dd.station':'Estación y Antena','dd.alerts':'Parámetros de Alerta', 'dd.installed':'Cartas instaladas','dd.install':'Instalar celda (.000)','dd.noaa':'Descargar cartas NOAA', 'dd.recordings':'Registros de buques (VDR)','dd.history':'Historial AIS','dd.export':'Exportar datos (CSV)', 'dd.manage':'Gestionar usuarios','dd.create':'Crear usuario', 'tb.port':'PUERTO','tb.port.ph':'Buscar puerto mundial...', 'tb.display':'MOSTRAR', 'tb.all':'TODO','tb.vessels':'BUQUES','tb.aids':'AYUDAS','tb.alerts':'ALERTAS', 'tb.layer':'CAPA','tb.world':'MUNDO','tb.seamark':'BOYAS', 'tb.land':'TIERRA','tb.bathy':'BATI','tb.hazards':'PELIGROS','tb.zones':'ZONAS', 'tb.night':'NOCHE','tb.day':'DÍA', 'stat.vessels':'BUQUES','stat.aids':'AYUDAS','stat.link':'ENLACE', 'sector.info':'INFO OBJETO','sector.events':'EVENTOS Y ALARMAS', 'no.sel':'SELECCIONE UN OBJETO EN LA CARTA', 'btn.edit':'EDITAR AYUDA','btn.track':'RASTREAR Y PROYECTAR RUMBO', }, }; let currentLang = localStorage.getItem('ams_lang') || 'en'; function applyLang(lang) { currentLang = lang; localStorage.setItem('ams_lang', lang); const t = LANG[lang]; document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.dataset.i18n; if (t[key]) el.textContent = t[key]; }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.dataset.i18nPlaceholder; if (t[key]) el.placeholder = t[key]; }); const btn = document.getElementById('toggle-lang'); if (btn) { btn.textContent = lang === 'en' ? 'ES' : 'EN'; } window._currentLang = lang; } document.getElementById('toggle-lang')?.addEventListener('click', () => { applyLang(currentLang === 'en' ? 'es' : 'en'); }); // Apply saved lang on load applyLang(currentLang); // ── Toggle SeaMap ────────────────────────────────────────────────────────── document.getElementById('toggle-seamap').addEventListener('click', (e) => { const visible = seaMapLayer.getVisible(); seaMapLayer.setVisible(!visible); e.target.classList.toggle('active', !visible); // After OL processes the visibility change, recompute ENC cell hiding so // the toggle can't leave ENC sources in a stale state (Miami disappear bug). requestAnimationFrame(() => { _recomputeHiddenCells(); [encSource, depthSource, landSource, zoneSource, hazardSource, soundSource] .forEach(src => src.changed()); }); }); // ── Toggle ENC chart aids ─────────────────────────────────────────────────── document.getElementById('toggle-enc').addEventListener('click', (e) => { const visible = encLayer.getVisible(); encLayer.setVisible(!visible); e.target.classList.toggle('active', !visible); }); // ── Toggle depths (DEPARE + DEPCNT + SOUNDG + LANDMASK) ───────────────────── document.getElementById('toggle-depths')?.addEventListener('click', (e) => { const willShow = !depthLayer.getVisible(); depthLayer.setVisible(willShow); soundLayer.setVisible(willShow); // sondas viajan junto a la batimetría e.target.classList.toggle('active', willShow); if (willShow) { _lastDepthBbox = null; window.loadChartDepths(); } }); // ── Toggle land / terrain (LNDARE, BUAARE, COALNE, structures) ─────────────── document.getElementById('toggle-land')?.addEventListener('click', (e) => { const willShow = !landLayer.getVisible(); landLayer.setVisible(willShow); e.target.classList.toggle('active', willShow); }); // ── Toggle hazards (WRECKS, OBSTRN, UWTROC) ────────────────────────────────── document.getElementById('toggle-hazards')?.addEventListener('click', (e) => { const willShow = !hazardLayer.getVisible(); hazardLayer.setVisible(willShow); e.target.classList.toggle('active', willShow); }); // ── Toggle zones (RESARE, CTNARE, ACHARE, TSSLPT, FAIRWY…) ────────────────── document.getElementById('toggle-zones')?.addEventListener('click', (e) => { const willShow = !zoneLayer.getVisible(); zoneLayer.setVisible(willShow); e.target.classList.toggle('active', willShow); }); // Reload depths whenever the user pans/zooms. The check inside loadChartDepths // short-circuits if the layer is hidden or the new viewport is already cached. map.on('moveend', _scheduleDepthReload); // Recompute which cells are dominated by a higher-band cell on every move. map.on('moveend', () => { _recomputeHiddenCells(); [encSource, depthSource, landSource, zoneSource, hazardSource] .forEach(src => src.changed()); }); // First load: fire once the map has rendered its initial frame (viewport known) map.once('rendercomplete', () => { _lastDepthBbox = null; window.loadChartDepths(); }); _loadChartCells(); // ── Toggle CHART mode — hides OSM/ocean tiles and renders the LANDMASK // polygon from the S-57 cells in paper-buff so the chart becomes the // sole basemap. Auto-enables DEPTHS because CHART mode without depth // fills shows nothing in the water. ── document.getElementById('toggle-chart')?.addEventListener('click', (e) => { _chartModeActive = !_chartModeActive; e.target.classList.toggle('active', _chartModeActive); osmLayer.setVisible(!_chartModeActive); oceanRefLayer.setVisible(!_chartModeActive); if (_chartModeActive && !depthLayer.getVisible()) { document.getElementById('toggle-depths').click(); } depthLayer.changed(); // force restyle so LANDMASK appears/disappears }); // ── Toggle WORLD raster — hide/show OSM basemap independently of CHART mode. document.getElementById('toggle-world')?.addEventListener('click', (e) => { const visible = !osmLayer.getVisible(); osmLayer.setVisible(visible); e.target.classList.toggle('active', visible); }); // ── Toggle Night Mode (dim tiles, keep aid/vessel layers at full brightness) ─ function applyNightMode(night) { osmLayer.setOpacity(night ? 0.45 : 1.0); oceanRefLayer.setOpacity(night ? 0.35 : 0.9); seaMapLayer.setOpacity(night ? 0.55 : 1.0); } document.getElementById('toggle-night').addEventListener('click', (e) => { const night = document.documentElement.classList.toggle('night'); e.target.classList.toggle('active', night); const t = LANG[currentLang] || LANG.en; e.target.textContent = night ? (t['tb.day'] || 'DAY') : (t['tb.night'] || 'NIGHT'); e.target.dataset.i18n = night ? 'tb.day' : 'tb.night'; applyNightMode(night); localStorage.setItem('ams_night', night ? '1' : ''); }); if (localStorage.getItem('ams_night')) { document.documentElement.classList.add('night'); document.getElementById('toggle-night').classList.add('active'); document.getElementById('toggle-night').textContent = 'DAY'; applyNightMode(true); } // ── SDR / AIS-catcher control ───────────────────────────────────────────── const _sdrBtn = document.getElementById('btn-sdr'); let _sdrRunning = false; async function _sdrRefreshStatus() { try { const r = await fetch('/ais/status'); const s = await r.json(); _sdrRunning = s.running; if (_sdrBtn) { _sdrBtn.classList.toggle('active', _sdrRunning); _sdrBtn.title = _sdrRunning ? `AIS-catcher running (PID ${s.pid ?? '?'}) — ${s.sdr_devices?.length ?? 0} SDR device(s)` : `Launch AIS-catcher — ${s.sdr_devices?.length ?? 0} RTL-SDR device(s) found`; if (!s.exe_found) _sdrBtn.title = 'AIS-catcher.exe not found'; } } catch (e) { /* backend not ready */ } } _sdrRefreshStatus(); _sdrBtn?.addEventListener('click', async () => { // Always POST /ais/launch — the backend handles "already running" by syncing // the source to SDR + restarting the UDP listener, which is exactly the // recovery action a user wants when the button "looks active but no data". _sdrBtn.disabled = true; _sdrBtn.textContent = '...'; try { const r = await fetch('/ais/launch', { method: 'POST' }); const d = await r.json(); if (d.ok) { _sdrRunning = true; _sdrBtn.classList.add('active'); _sdrBtn.title = `AIS-catcher running (PID ${d.pid ?? '?'})`; } else { alert(`AIS-catcher launch failed:\n${d.error || JSON.stringify(d)}`); } } catch (e) { alert(`SDR launch error: ${e}`); } finally { _sdrBtn.disabled = false; _sdrBtn.textContent = 'SDR'; } }); // ── Historial y proyección de barcos ────────────────────────────────────── const vesselHistory = new Map(); // mmsi → [{lat,lon,ts}] const trackSource = new ol.source.Vector(); const trackLayer = new ol.layer.Vector({ source: trackSource, zIndex: 5 }); map.addLayer(trackLayer); // ── Auto-trail — estela en tiempo real para todos los targets AIS ────────── // Dibuja automáticamente el rastro de cada barco conforme llegan posiciones. const trailSource = new ol.source.Vector(); const trailLayer = new ol.layer.Vector({ source: trailSource, zIndex: 6, style: function(feature) { var color = feature.get('color') || '#00bfff'; return new ol.style.Style({ stroke: new ol.style.Stroke({ color: color, width: 1.5, lineDash: [4, 4] }), }); }, }); map.addLayer(trailLayer); var _trailsVisible = true; var _trailWindowMs = 6 * 60 * 1000; // default: last 6 minutes function _updateTrail(mmsi) { var hist = vesselHistory.get(mmsi); if (!hist) return; var cutoff = (_trailWindowMs > 0) ? (Date.now() - _trailWindowMs) : 0; var pts = hist.filter(function(p) { return p.ts >= cutoff; }); var feat = trailSource.getFeatureById('trail_' + mmsi); if (pts.length < 2) { if (feat) trailSource.removeFeature(feat); return; } var coords = pts.map(function(p) { return ol.proj.fromLonLat([p.lon, p.lat]); }); if (!feat) { var f = new ol.Feature({ geometry: new ol.geom.LineString(coords) }); f.setId('trail_' + mmsi); f.set('color', '#00bfff'); trailSource.addFeature(f); } else { feat.getGeometry().setCoordinates(coords); } } window.setTrailsVisible = function(v) { _trailsVisible = v; trailLayer.setVisible(v); }; window.setTrailWindow = function(ms) { _trailWindowMs = ms; // Redraw all trails under the new window vesselHistory.forEach(function(hist, mmsi) { _updateTrail(mmsi); }); }; // ── AIS Vectors (True / Relative) ───────────────────────────────────────── const vectorSource = new ol.source.Vector(); const vectorLayer = new ol.layer.Vector({ source: vectorSource, zIndex: 7, visible: false, style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: '#ffff00', width: 1.5 }), }), }); map.addLayer(vectorLayer); var _vectorsVisible = false; var _vectorMode = 'true'; // 'true' | 'relative' var _vectorTimeMin = 6; // minutes ahead var _ownShipCog = 0; var _ownShipSog = 0; function _updateVector(mmsi, lat, lon, sog, cog) { var feat = vectorSource.getFeatureById('vec_' + mmsi); if (!_vectorsVisible || !(sog > 0.1)) { if (feat) vectorSource.removeFeature(feat); return; } var effSog = sog, effCog = cog; if (_vectorMode === 'relative') { var ownVx = _ownShipSog * Math.sin(_ownShipCog * Math.PI / 180); var ownVy = _ownShipSog * Math.cos(_ownShipCog * Math.PI / 180); var tgtVx = sog * Math.sin(cog * Math.PI / 180); var tgtVy = sog * Math.cos(cog * Math.PI / 180); var relVx = tgtVx - ownVx; var relVy = tgtVy - ownVy; effSog = Math.sqrt(relVx * relVx + relVy * relVy); effCog = (Math.atan2(relVx, relVy) * 180 / Math.PI + 360) % 360; } var pts = projectPath(lat, lon, effSog, effCog, _vectorTimeMin); var end = pts[pts.length - 1]; var coords = [ol.proj.fromLonLat([lon, lat]), ol.proj.fromLonLat(end)]; if (!feat) { var f = new ol.Feature({ geometry: new ol.geom.LineString(coords) }); f.setId('vec_' + mmsi); vectorSource.addFeature(f); } else { feat.getGeometry().setCoordinates(coords); } } function _refreshAllVectors() { vesselsSource.getFeatures().forEach(function(f) { var d = f.getProperties(); if (d.mmsi != null) _updateVector(d.mmsi, d.lat, d.lon, d.sog || 0, d.cog || 0); }); } window.setVectorsVisible = function(v) { _vectorsVisible = v; vectorLayer.setVisible(v); if (!v) vectorSource.clear(); else _refreshAllVectors(); }; window.setVectorMode = function(m) { _vectorMode = m; _refreshAllVectors(); }; window.setVectorTime = function(t) { _vectorTimeMin = t; _refreshAllVectors(); }; // ── Indicadores de warning/alarma sobre ayudas ──────────────────────────── const warnSource = new ol.source.Vector(); const warnLayer = new ol.layer.Vector({ source: warnSource, zIndex: 25 }); map.addLayer(warnLayer); function warnStyle(level, visible) { if (!visible) return null; const isAlarm = level === 'alarm'; const color = isAlarm ? '#e53935' : '#f59e0b'; const stroke = isAlarm ? '#ff6b6b' : '#fcd34d'; // Two styles: triangle offset + "!" text at same offset return [ new ol.style.Style({ image: new ol.style.RegularShape({ points: 3, radius: 14, displacement: [22, 22], fill: new ol.style.Fill({ color }), stroke: new ol.style.Stroke({ color: stroke, width: 1.5 }), }), }), new ol.style.Style({ text: new ol.style.Text({ text: '!', font: 'bold 11px Inter,sans-serif', fill: new ol.style.Fill({ color: '#fff' }), offsetX: 22, offsetY: -22, }), }), ]; } // Blink loop — alterna entre estilo visible y estilo vacío (invisible) const STYLE_INVISIBLE = new ol.style.Style({}); let blinkOn = true; setInterval(() => { blinkOn = !blinkOn; warnSource.getFeatures().forEach(f => f.setStyle(blinkOn ? warnStyle(f.get('level'), true) : STYLE_INVISIBLE) ); }, 600); function setAidWarning(aidFeature, level) { const aidId = aidFeature.getId(); // Quitar indicador previo de esta ayuda si existe const existing = warnSource.getFeatureById('w_' + aidId); if (existing) warnSource.removeFeature(existing); if (!level) return; const geom = aidFeature.getGeometry().clone(); // Desplazar el triángulo 18px arriba (en coordenadas mapa no es trivial, se superpone) const f = new ol.Feature({ geometry: geom, level }); f.setId('w_' + aidId); f.setStyle(warnStyle(level, true)); warnSource.addFeature(f); } function clearAllWarnings() { warnSource.clear(); } window.setAidWarning = setAidWarning; window.clearAllWarnings = clearAllWarnings; // ── Posición propia (GPS) ────────────────────────────────────────────────── const ownShipSource = new ol.source.Vector(); map.addLayer(new ol.layer.Vector({ source: ownShipSource, zIndex: 30, style: new ol.style.Style({ image: new ol.style.RegularShape({ points: 4, radius: 10, radius2: 0, angle: Math.PI / 4, fill: new ol.style.Fill({ color: 'rgba(30,136,229,0.9)' }), stroke: new ol.style.Stroke({ color: '#fff', width: 1.5 }), }), text: new ol.style.Text({ text: 'MY POSITION', offsetY: -18, font: '500 9px Inter,sans-serif', fill: new ol.style.Fill({ color: '#90caf9' }), stroke: new ol.style.Stroke({ color: '#030810', width: 3 }), }), }), })); window.updateOwnShip = function(fix) { ownShipSource.clear(); // Capture COG/SOG for relative vector mode _ownShipCog = fix.cog || 0; _ownShipSog = fix.sog || 0; const f = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([fix.lon, fix.lat])), }); ownShipSource.addFeature(f); }; function recordPosition(mmsi, lat, lon) { if (!vesselHistory.has(mmsi)) vesselHistory.set(mmsi, []); const hist = vesselHistory.get(mmsi); const now = Date.now(); // Guardar solo si pasaron ≥30 s desde la última if (!hist.length || now - hist[hist.length - 1].ts >= 30000) hist.push({ lat, lon, ts: now }); if (hist.length > 40) hist.shift(); _updateTrail(mmsi); } function projectPath(lat, lon, sogKn, cogDeg, minutes = 20) { const sogMs = sogKn * 0.5144; const cogRad = (cogDeg * Math.PI) / 180; const pts = [[lon, lat]]; for (let t = 1; t <= minutes; t++) { const d = sogMs * t * 60; const dLat = (d * Math.cos(cogRad)) / 111320; const dLon = (d * Math.sin(cogRad)) / (111320 * Math.cos(lat * Math.PI / 180)); pts.push([lon + dLon, lat + dLat]); } return pts; } function haversineM(lat1, lon1, lat2, lon2) { const R = 6371000, r = Math.PI / 180; const dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r; const a = Math.sin(dLat/2)**2 + Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } let activeTrackMmsi = null; window.toggleTrack = function(mmsi, lat, lon, sog, cog) { // Limpiar track anterior trackSource.clear(); if (activeTrackMmsi === mmsi) { activeTrackMmsi = null; clearAllWarnings(); return; } activeTrackMmsi = mmsi; const hist = vesselHistory.get(mmsi) || []; // Track histórico if (hist.length >= 2) { const histCoords = hist.map(p => ol.proj.fromLonLat([p.lon, p.lat])); trackSource.addFeature(new ol.Feature({ geometry: new ol.geom.LineString(histCoords), kind: 'history', })); } // Proyección de rumbo (20 min) if (sog > 0.3) { const proj = projectPath(lat, lon, sog, cog, 20); const projCoords = proj.map(p => ol.proj.fromLonLat(p)); trackSource.addFeature(new ol.Feature({ geometry: new ol.geom.LineString(projCoords), kind: 'projection', })); // Detectar colisiones con ayudas y marcarlas en el mapa clearAllWarnings(); const warnings = []; aidsSource.getFeatures().forEach(aid => { const ac = ol.proj.toLonLat(aid.getGeometry().getCoordinates()); const borneo = aid.get('radio_borneo_m') || 30; let closest = Infinity, closestEta = 0; proj.forEach((pt, i) => { const d = haversineM(pt[1], pt[0], ac[1], ac[0]); if (d < closest) { closest = d; closestEta = i; } }); const level = closest < borneo + 50 ? 'alarm' : closest < borneo + 200 ? 'warning' : null; if (level) { setAidWarning(aid, level); warnings.push({ nombre: aid.get('nombre'), eta: closestEta, dist: Math.round(closest), level }); } }); // Actualizar panel con warnings const warnEl = document.getElementById('track-warnings'); if (warnEl) { if (warnings.length) { warnEl.innerHTML = warnings.map(w => `
${w.level === 'alarm' ? '🔴' : '🟡'} ${w.nombre} — ETA ~${w.eta} min (${w.dist} m)
` ).join(''); warnEl.style.display = 'block'; } else { warnEl.innerHTML = '
No aids in projected path.
'; warnEl.style.display = 'block'; } } } }; trackLayer.setStyle((feature) => { const kind = feature.get('kind'); if (kind === 'history') return new ol.style.Style({ stroke: new ol.style.Stroke({ color: '#06b6d4', width: 1.5, lineDash: [5, 4] }), }); if (kind === 'projection') return new ol.style.Style({ stroke: new ol.style.Stroke({ color: '#f59e0b', width: 1.5, lineDash: [8, 5] }), }); }); // ── Filtros ──────────────────────────────────────────────────────────────── document.querySelectorAll('.tb-btn[data-filter]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tb-btn[data-filter]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const f = btn.dataset.filter; vesselsLayer.setVisible(f === 'all' || f === 'vessels'); aidsLayer.setVisible(f === 'all' || f === 'aids'); }); }); // ── Reloj UTC ────────────────────────────────────────────────────────────── setInterval(() => { document.getElementById('clock').textContent = new Date().toUTCString().slice(17, 25); }, 1000); // ── Divisor arrastrable ──────────────────────────────────────────────────── (function() { const divider = document.getElementById('panel-divider'); const infoSector = document.getElementById('info-sector'); const evSector = document.getElementById('events-sector'); let dragging = false, startY = 0, startInfo = 0, startEv = 0; divider.addEventListener('mousedown', (e) => { dragging = true; startY = e.clientY; startInfo = infoSector.getBoundingClientRect().height; startEv = evSector.getBoundingClientRect().height; divider.classList.add('dragging'); document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const dy = e.clientY - startY; const newInfo = Math.max(80, startInfo + dy); const newEv = Math.max(80, startEv - dy); infoSector.style.flex = `0 0 ${newInfo}px`; evSector.style.flex = `0 0 ${newEv}px`; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; divider.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; }); })(); // Exponer aidsSource globalmente para el formulario window.aidsSource = aidsSource; // ── Port constraints for USER role ──────────────────────────────────────── // Called after login when the user has a company → port. // ── Port constraint state ───────────────────────────────────────────────────── let _portMinZoom = null; // minimum zoom for client users let _portExtent = null; // [minX,minY,maxX,maxY] in EPSG:3857 let _constraintLock = false; // prevents re-entrant moveend feedback loop // Single moveend listener — hard backstop for zoom + pan constraints. // Lock prevents the corrective setCenter/setZoom from re-triggering the // listener in a loop that would cause sluggishness. map.on('moveend', function() { if (_constraintLock) return; if (_portMinZoom === null && !_portExtent) return; const view = map.getView(); let newZoom = view.getZoom(); let newCenter = view.getCenter().slice(); let needsFix = false; // Zoom floor if (_portMinZoom !== null && newZoom < _portMinZoom) { newZoom = _portMinZoom; needsFix = true; } // Pan bounds — clamp center to chart extent if (_portExtent) { const cx = Math.max(_portExtent[0], Math.min(_portExtent[2], newCenter[0])); const cy = Math.max(_portExtent[1], Math.min(_portExtent[3], newCenter[1])); if (cx !== newCenter[0] || cy !== newCenter[1]) { newCenter = [cx, cy]; needsFix = true; } } if (needsFix) { _constraintLock = true; view.setCenter(newCenter); view.setZoom(newZoom); setTimeout(function() { _constraintLock = false; }, 150); } }); window._setPortConstraints = function(port) { if (!port || port.center_lat == null) return; _portMinZoom = port.default_zoom || 12; // Extent: real ENC chart bbox preferred, fallback ±0.25° pad. // chart_bbox = [west, south, east, north] WGS-84. if (port.chart_bbox && port.chart_bbox.length === 4) { const [west, south, east, north] = port.chart_bbox; const sw = ol.proj.fromLonLat([west, south]); const ne = ol.proj.fromLonLat([east, north]); _portExtent = [sw[0], sw[1], ne[0], ne[1]]; } else { const pad = 0.25; const sw = ol.proj.fromLonLat([port.center_lon - pad, port.center_lat - pad]); const ne = ol.proj.fromLonLat([port.center_lon + pad, port.center_lat + pad]); _portExtent = [sw[0], sw[1], ne[0], ne[1]]; } map.getView().setMinZoom(_portMinZoom); }; window._clearPortConstraints = function() { _portMinZoom = null; _portExtent = null; map.getView().setMinZoom(0); }; // Returns true once the map view is usable. window._mapReady = function() { try { return !!map.getView(); } catch (_) { return false; } }; // Call after the map container becomes visible (app shown after login). // OL initializes with display:none → size=0×0. updateSize() fixes both // the rendering size and re-anchors the center to the correct position. window._mapUpdateSize = function() { map.updateSize(); }; // Jump to position immediately — no animation (used for client role login). window._mapJump = function(lon, lat, zoom) { const view = map.getView(); view.setCenter(ol.proj.fromLonLat([lon, lat])); view.setZoom(zoom); }; // ── DVR Playback hooks ───────────────────────────────────────────────────── // dvr.js calls these to move a marker and draw the track on the map. (function() { const dvrSource = new ol.source.Vector(); const dvrLayer = new ol.layer.Vector({ source: dvrSource, zIndex: 50, // above everything visible: false, }); map.addLayer(dvrLayer); // Feature IDs const MARKER_ID = '__dvr_marker__'; const TRACK_ID = '__dvr_track__'; // ── Move playback marker ───────────────────────────────────────────────── window._dvrMapMove = function(lon, lat, cog) { dvrLayer.setVisible(true); let feat = dvrSource.getFeatureById(MARKER_ID); const coord = ol.proj.fromLonLat([lon, lat]); if (!feat) { feat = new ol.Feature({ geometry: new ol.geom.Point(coord) }); feat.setId(MARKER_ID); feat.setStyle(new ol.style.Style({ image: new ol.style.RegularShape({ fill: new ol.style.Fill({ color: '#00e5ff' }), stroke: new ol.style.Stroke({ color: '#003344', width: 1.5 }), points: 3, radius: 10, rotation: ((cog || 0) * Math.PI) / 180, rotateWithView: true, }), zIndex: 100, })); dvrSource.addFeature(feat); } else { feat.getGeometry().setCoordinates(coord); // Update triangle rotation const s = feat.getStyle(); if (s && s.getImage()) { s.getImage().setRotation(((cog || 0) * Math.PI) / 180); } } // Pan map to keep marker in view if it's near the edge const view = map.getView(); const extent = view.calculateExtent(map.getSize()); if (!ol.extent.containsCoordinate(extent, coord)) { view.animate({ center: coord, duration: 400 }); } }; // ── Draw full track polyline ───────────────────────────────────────────── window._dvrMapShowTrack = function(track) { dvrLayer.setVisible(true); // Remove existing track line const existing = dvrSource.getFeatureById(TRACK_ID); if (existing) dvrSource.removeFeature(existing); if (!track || !track.length) return; const coords = track .filter(p => p.lat != null && p.lon != null) .map(p => ol.proj.fromLonLat([p.lon, p.lat])); if (coords.length < 2) return; const line = new ol.Feature({ geometry: new ol.geom.LineString(coords), }); line.setId(TRACK_ID); line.setStyle(new ol.style.Style({ stroke: new ol.style.Stroke({ color: '#00e5ff', width: 2, lineDash: [6, 4], }), })); dvrSource.addFeature(line); // Fit view to track extent with padding const ext = dvrSource.getExtent(); map.getView().fit(ext, { padding: [60, 60, 60, 60], duration: 800 }); }; // ── Clear DVR layer (called on stop) ──────────────────────────────────── window._dvrMapClear = function() { dvrSource.clear(); dvrLayer.setVisible(false); }; })(); // ── AIS toolbar controls ─────────────────────────────────────────────────── document.getElementById('btn-trails').addEventListener('click', function(e) { const on = e.target.classList.toggle('active'); window.setTrailsVisible(on); }); document.getElementById('trail-window').addEventListener('change', function(e) { window.setTrailWindow(Number(e.target.value)); }); document.getElementById('btn-vectors').addEventListener('click', function(e) { const on = e.target.classList.toggle('active'); window.setVectorsVisible(on); }); document.getElementById('vector-mode').addEventListener('change', function(e) { window.setVectorMode(e.target.value); }); document.getElementById('vector-time').addEventListener('change', function(e) { window.setVectorTime(Number(e.target.value)); });