Files
AidsMonitoring/frontend/js/map.js
T
alro65 750d4ecbd2 Rediseñar símbolos BCNLAT y LIGHTS + land Barranquilla
BCNLAT (_encBeaconCanvas): rectángulo delgado y alto en color IALA.
  Babor  (puerto, verde IALA-B): tope plano = rectángulo puro.
  Estribor (estribor, rojo IALA-B): tope triangular apuntando arriba +
  cuerpo rectangular debajo. Gradiente lateral 3-D. Aplica igual a
  daymarks del ICW Miami y balizas de orilla Colombia.

LIGHTS blanco (_ialaLight): rectángulo cuerpo blanco borde negro,
  estrella de 5 puntas centrada, lagrima estirada purpura a ~20° de
  la vertical saliendo del costado superior del rectángulo (S-52 style).

land.geojson BARRANQUILLA: polígono único trazando la costa desde
  Galerazamba (O) hasta la boca del canal Bocas de Ceniza (E),
  incluyendo ambas riberas del canal y la costa sur hasta el límite
  de la carta. Corrige tierra en 0 features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 20:37:10 -04:00

3628 lines
156 KiB
JavaScript
Raw Blame History

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