123c983ada
Rediseño del símbolo de baliza lateral para reflejar la estructura real IALA: palo continuo delgado + cuerpo rectangular (parte baja) + tramo de palo libre visible (hueco) + marca de tope separada arriba. Babor (IALA-B verde): marca de tope = cuadrado. Estribor (IALA-B rojo): marca de tope = triángulo apuntando arriba. La marca de tope es levemente más ancha que el cuerpo. El hueco entre cuerpo y marca queda ocupado solo por el poste delgado. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3651 lines
156 KiB
JavaScript
3651 lines
156 KiB
JavaScript
'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) — poste + cuerpo + HUECO + marca de tope ─────────
|
||
// Estructura real IALA: palo delgado continuo + cuerpo rectangular en la parte
|
||
// inferior + tramo de palo libre (hueco visible) + marca de tope separada arriba.
|
||
// Babor (port, IALA-B verde): marca de tope = cuadrado.
|
||
// Estribor (stbd, IALA-B rojo): marca de tope = triángulo apuntando arriba.
|
||
// La marca de tope es levemente más ancha que el cuerpo y queda separada de él.
|
||
function _encBeaconCanvas(colours, catlam, region, sz = 36) {
|
||
const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
|
||
let cc = (colours || []).slice();
|
||
if (!cc.length && catlam) cc = _ialaLateralColours(catlam, region);
|
||
const col = cc[0] ? _s57css(cc[0]) : '#78909c';
|
||
const isPort = catlam === 1 || catlam === 3; // IALA-B: babor=verde, estribor=rojo
|
||
|
||
// ── Layout: poste continuo + cuerpo (bajo) + hueco + marca de tope (arriba) ─
|
||
const groundY = sz * 0.92; // base / nivel del suelo
|
||
const poleTopY = sz * 0.04; // cima del poste
|
||
const poleW = sz * 0.055; // grosor del poste delgado
|
||
|
||
// Cuerpo (shaft): rectángulo en la mitad inferior del poste
|
||
const bodyW = sz * 0.28;
|
||
const bodyTopY = sz * 0.48; // tope del cuerpo → aquí empieza el hueco visible
|
||
const bodyBotY = groundY;
|
||
|
||
// Marca de tope: separada del cuerpo, levemente más ancha
|
||
const tmW = sz * 0.36; // ancho de la marca de tope
|
||
const tmBotY = sz * 0.27; // base de la marca (límite superior del hueco)
|
||
const tmTopY = sz * 0.05; // cima de la marca (= ≈ tope del poste)
|
||
const tmH = tmBotY - tmTopY;
|
||
|
||
// ── Sombra elíptica en la base ────────────────────────────────────────────
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, groundY, sz * 0.12, sz * 0.020, 0, 0, Math.PI * 2);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
||
ctx.fill();
|
||
|
||
// ── Poste continuo (todo el alto, dibujado primero = detrás) ─────────────
|
||
const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0);
|
||
poleGrd.addColorStop(0, '#999');
|
||
poleGrd.addColorStop(0.4, '#ddd');
|
||
poleGrd.addColorStop(1, '#777');
|
||
ctx.beginPath();
|
||
ctx.rect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY);
|
||
ctx.fillStyle = poleGrd;
|
||
ctx.fill();
|
||
|
||
// ── Gradiente de color IALA (compartido por cuerpo y marca de tope) ───────
|
||
const mkGrd = (w) => {
|
||
const g = ctx.createLinearGradient(cx - w / 2, 0, cx + w / 2, 0);
|
||
g.addColorStop(0, _lighten3D(col, 0.35));
|
||
g.addColorStop(0.45, col);
|
||
g.addColorStop(1, _darken3D(col, 0.28));
|
||
return g;
|
||
};
|
||
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.65)';
|
||
ctx.lineWidth = 0.9;
|
||
|
||
// ── Cuerpo (shaft rectangulo, parte inferior) ─────────────────────────────
|
||
ctx.beginPath();
|
||
ctx.rect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
|
||
ctx.fillStyle = mkGrd(bodyW);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
// Highlight lateral izquierdo
|
||
ctx.beginPath();
|
||
ctx.rect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.fill();
|
||
|
||
// ── Marca de tope (separada del cuerpo por el hueco = tramo de poste libre) ─
|
||
if (isPort) {
|
||
// Babor: cuadrado (square daymark)
|
||
ctx.beginPath();
|
||
ctx.rect(cx - tmW / 2, tmTopY, tmW, tmH);
|
||
ctx.fillStyle = mkGrd(tmW);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
// Highlight lateral
|
||
ctx.beginPath();
|
||
ctx.rect(cx - tmW / 2, tmTopY, tmW * 0.20, tmH);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.fill();
|
||
} else {
|
||
// Estribor: triángulo apuntando hacia arriba (triangle daymark)
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, tmTopY); // vértice superior (apex)
|
||
ctx.lineTo(cx + tmW / 2, tmBotY); // esquina inferior derecha
|
||
ctx.lineTo(cx - tmW / 2, tmBotY); // esquina inferior izquierda
|
||
ctx.closePath();
|
||
ctx.fillStyle = mkGrd(tmW);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
|
||
return c;
|
||
}
|
||
|
||
// ── Range / Leading-mark dayboard — dos triángulos concéntricos ──────────────
|
||
// Símbolo IALA: triángulo exterior BLANCO + interior NEGRO con lados
|
||
// estrictamente paralelos (triángulo similar centrado en el baricentro).
|
||
// Tamaño reducido para no dominar el mapa.
|
||
function _encRangeDaymarkCanvas(colours, hasLight, sz = 34) {
|
||
const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
|
||
|
||
// Light colour for optional flare
|
||
const lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff';
|
||
|
||
// ── Layout ───────────────────────────────────────────────────────────────
|
||
const triTop = sz * 0.06; // apex of outer triangle
|
||
const triBot = sz * 0.94; // base of outer triangle
|
||
const halfW = sz * 0.44; // half-width at base
|
||
|
||
// ── OUTER triangle — WHITE ───────────────────────────────────────────────
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, triTop);
|
||
ctx.lineTo(cx + halfW, triBot);
|
||
ctx.lineTo(cx - halfW, triBot);
|
||
ctx.closePath();
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#555';
|
||
ctx.lineWidth = 1.0;
|
||
ctx.stroke();
|
||
|
||
// ── INNER triangle — BLACK, lados paralelos al exterior ──────────────────
|
||
// Escalar desde el baricentro garantiza lados paralelos (triángulo similar).
|
||
const r = 0.48; // escala del interior
|
||
const cy = (triTop + 2 * triBot) / 3; // baricentro Y del triángulo exterior
|
||
const iApexY = cy + r * (triTop - cy); // vértice superior del interior
|
||
const iBaseY = cy + r * (triBot - cy); // base del interior
|
||
const iHalf = halfW * r; // media-anchura → lados paralelos
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, iApexY);
|
||
ctx.lineTo(cx + iHalf, iBaseY);
|
||
ctx.lineTo(cx - iHalf, iBaseY);
|
||
ctx.closePath();
|
||
ctx.fillStyle = '#111111';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#000';
|
||
ctx.lineWidth = 0.6;
|
||
ctx.stroke();
|
||
|
||
// ── Light flare al ápice (si la feature tiene luz) ───────────────────────
|
||
if (hasLight) {
|
||
_drawLightFlare(ctx, cx, triTop - sz * 0.02, lightCss);
|
||
}
|
||
|
||
return c;
|
||
}
|
||
|
||
// ── Cardinal beacon (BCNCAR) — tripod + two-cone topmark ─────────────────────
|
||
function _encBcnCardinalCanvas(quadrant, sz = 30) {
|
||
const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
|
||
const BLK = '#111'; const baseY = sz * 0.90, poleTop = sz * 0.30;
|
||
|
||
ctx.strokeStyle = '#444'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx - sz*0.22, baseY); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(cx, baseY * 0.72); ctx.lineTo(cx + sz*0.22, baseY); ctx.stroke();
|
||
ctx.strokeStyle = '#444'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(cx, baseY); ctx.lineTo(cx, poleTop); ctx.stroke();
|
||
|
||
const cw = sz * 0.42, ch = sz * 0.16;
|
||
const t1 = poleTop - sz * 0.02;
|
||
const t2 = t1 - ch - sz * 0.01;
|
||
if (quadrant === 'N') {
|
||
_tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK);
|
||
} else if (quadrant === 'S') {
|
||
_tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK);
|
||
} else if (quadrant === 'E') {
|
||
_tmConeUp(ctx, cx, t2, cw, ch, BLK); _tmConeDown(ctx, cx, t1, cw, ch, BLK);
|
||
} else {
|
||
_tmConeDown(ctx, cx, t2, cw, ch, BLK); _tmConeUp(ctx, cx, t1, cw, ch, BLK);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Multi-cell composition (port from AR ECDIS).
|
||
- usage_band derived from the 3rd char of the cell id (IHO/NOAA convention):
|
||
US1GC09M → 1 Overview, US5MIA* → 5 Harbour, etc.
|
||
- Each band is rendered only inside its own zoom range. Cells with band 0
|
||
(unknown) are always rendered.
|
||
- When a higher-band cell covers ≥40% of the viewport, lower-band cells are
|
||
hidden — so the harbour cell takes over while the overview stays loaded
|
||
for panning to less detailed areas.
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
window._chartCells = {}; // cell_id → { bbox: [w,s,e,n], band: int }
|
||
const _HIDDEN_CELLS = new Set(); // mutated on moveend by _recomputeHiddenCells
|
||
|
||
// Band zoom ranges: [minZoom, maxZoom].
|
||
// Band 0 (custom/BARRANQUILLA) siempre visible [0,24].
|
||
// Bands 1-4 tienen maxZoom para que celdas overview no compitan con harbour
|
||
// en zoom alto — sin estos límites, US1GC09M (549 features Overview) aparece
|
||
// en zoom 11 sobre Miami y su declutter oculta las boyas de mayor resolución.
|
||
// BARRANQUILLA no se afecta — su cell_id da band=0 por _bandFromCellId.
|
||
const BAND_ZOOM_RANGES = {
|
||
0: [0, 24], // custom / unknown (ej. BARRANQUILLA) → siempre visible
|
||
1: [0, 10], // Overview (1:600 000+) — se oculta a zoom >10
|
||
2: [3, 12], // General (1:350 000)
|
||
3: [6, 15], // Coastal (1:90 000)
|
||
4: [8, 18], // Approach (1:25 000)
|
||
5: [10, 24], // Harbour (1:7 500)
|
||
6: [12, 24], // Berthing (1:2 000)
|
||
};
|
||
|
||
function _bandFromCellId(id) {
|
||
const m = (id || '').match(/^[A-Za-z]{2}([1-6])/);
|
||
return m ? +m[1] : 0;
|
||
}
|
||
|
||
// Returns false if this feature's cell should NOT render at the current resolution.
|
||
function _cellShouldRender(cellId, resolution) {
|
||
if (!cellId) return true;
|
||
if (_HIDDEN_CELLS.has(cellId)) return false;
|
||
const meta = window._chartCells[cellId];
|
||
const band = meta ? meta.band : _bandFromCellId(cellId);
|
||
const zoom = map.getView().getZoomForResolution(resolution);
|
||
const [zmin, zmax] = BAND_ZOOM_RANGES[band] || BAND_ZOOM_RANGES[0];
|
||
return zoom >= zmin && zoom <= zmax;
|
||
}
|
||
|
||
// On moveend, hide overview cells whose viewport overlap is dominated by a
|
||
// higher-band cell (≥40% of the viewport area covered by the higher one).
|
||
// IMPORTANT: only count a higher-band cell as "dominating" if that cell is
|
||
// actually within its own zoom range at the current zoom. Otherwise band-4
|
||
// cells get hidden by a band-5 cell that is itself invisible (zoom < 10),
|
||
// leaving a gap where NO chart renders at all (the Miami disappear bug).
|
||
function _recomputeHiddenCells() {
|
||
_HIDDEN_CELLS.clear();
|
||
const cells = window._chartCells;
|
||
if (!cells || Object.keys(cells).length === 0) return;
|
||
const ext = map.getView().calculateExtent(map.getSize());
|
||
const zoom = map.getView().getZoom() || 0;
|
||
const [w, s, e, n] = ol.proj.transformExtent(ext, 'EPSG:3857', 'EPSG:4326');
|
||
const vArea = (e - w) * (n - s);
|
||
if (vArea <= 0) return;
|
||
|
||
for (const [cellId, meta] of Object.entries(cells)) {
|
||
if (!meta.bbox) continue;
|
||
for (const [otherId, other] of Object.entries(cells)) {
|
||
if (otherId === cellId || !other.bbox) continue;
|
||
if (other.band <= meta.band) continue;
|
||
// Only treat 'other' as dominating if it is actually visible at current zoom.
|
||
// Without this check, a harbour cell (band-5, minZoom=10) hides the approach
|
||
// cell (band-4) even at zoom 9.x, where band-5 is itself out of range → nothing renders.
|
||
const [ozMin, ozMax] = BAND_ZOOM_RANGES[other.band] || BAND_ZOOM_RANGES[0];
|
||
if (zoom < ozMin || zoom > ozMax) continue;
|
||
const iw = Math.max(0, Math.min(e, other.bbox[2]) - Math.max(w, other.bbox[0]));
|
||
const ih = Math.max(0, Math.min(n, other.bbox[3]) - Math.max(s, other.bbox[1]));
|
||
if ((iw * ih) / vArea >= 0.40) { _HIDDEN_CELLS.add(cellId); break; }
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _loadChartCells() {
|
||
try {
|
||
const r = await fetch('/charts/cells');
|
||
const cells = await r.json();
|
||
cells.forEach(c => {
|
||
window._chartCells[c.id] = {
|
||
bbox: c.bbox,
|
||
band: _bandFromCellId(c.id),
|
||
};
|
||
});
|
||
_recomputeHiddenCells();
|
||
[encSource, depthSource, landSource, zoneSource, hazardSource, soundSource]
|
||
.forEach(src => src.changed());
|
||
} catch (e) {
|
||
console.warn('[charts] failed to load cell catalog:', e);
|
||
}
|
||
}
|
||
|
||
// ── Leading line (enfilación) style ──────────────────────────────────────
|
||
// S-52: leading line = dashed orange/magenta line with arrowhead at the far
|
||
// end showing the bearing direction. The line is always rendered (no zoom
|
||
// threshold) so navigators can see it at any scale.
|
||
function _ldlineStyle(feature) {
|
||
const orient = feature.get('orient'); // True bearing, degrees
|
||
const colours = feature.get('colours') || [];
|
||
// Colour: use the light's own colour when available, else default magenta
|
||
const col = colours[0] === 4 ? '#00cc44' // green light → green line
|
||
: colours[0] === 3 ? '#ff4444' // red light → red line
|
||
: '#ff8800'; // default orange (leading line)
|
||
const lineStyle = new ol.style.Style({
|
||
stroke: new ol.style.Stroke({
|
||
color: col,
|
||
width: 1.6,
|
||
lineDash: [8, 5],
|
||
}),
|
||
});
|
||
|
||
const styles = [lineStyle];
|
||
|
||
// Arrowhead at the far end of the line (bearing direction)
|
||
if (orient != null) {
|
||
const coords = feature.getGeometry().getCoordinates();
|
||
const end = coords[coords.length - 1];
|
||
const start = coords[coords.length - 2];
|
||
// Angle of the line in screen space (OL is EPSG:3857)
|
||
const dx = end[0] - start[0], dy = end[1] - start[1];
|
||
const angle = Math.atan2(dy, dx); // radians, CCW from east
|
||
// Small arrowhead canvas
|
||
const asz = 14;
|
||
const ac = document.createElement('canvas');
|
||
ac.width = asz; ac.height = asz;
|
||
const ax = ac.getContext('2d');
|
||
ax.strokeStyle = col; ax.lineWidth = 2; ax.lineCap = 'round';
|
||
ax.translate(asz / 2, asz / 2);
|
||
ax.rotate(-angle); // align with line direction
|
||
ax.beginPath();
|
||
ax.moveTo(-5, -4); ax.lineTo(0, 0); ax.lineTo(-5, 4);
|
||
ax.stroke();
|
||
styles.push(new ol.style.Style({
|
||
geometry: new ol.geom.Point(end),
|
||
image: new ol.style.Icon({
|
||
img: ac, imgSize: [asz, asz],
|
||
rotation: -angle, // OL rotation is CW
|
||
rotateWithView: true,
|
||
}),
|
||
}));
|
||
}
|
||
|
||
// Bearing label — always visible, centred on the line.
|
||
// Format: "143.5° E12 — E16" (bearing + paired mark names)
|
||
const name = feature.get('name') || '';
|
||
const bearTxt = orient != null ? `${orient % 1 === 0 ? orient : orient.toFixed(1)}°` : '';
|
||
const lbl = [bearTxt, name].filter(Boolean).join(' ');
|
||
if (lbl) {
|
||
const coords = feature.getGeometry().getCoordinates();
|
||
// Place label at 1/3 from start (near the front mark) so it's in the channel
|
||
const idx = Math.max(0, Math.floor(coords.length / 3));
|
||
const mid = coords[idx];
|
||
styles.push(new ol.style.Style({
|
||
geometry: new ol.geom.Point(mid),
|
||
text: new ol.style.Text({
|
||
text: lbl,
|
||
font: 'bold 10px "Inter", "Segoe UI", sans-serif',
|
||
fill: new ol.style.Fill({ color: '#fff' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(0,0,0,0.50)', width: 2 }),
|
||
offsetY: -12,
|
||
overflow: true,
|
||
}),
|
||
}));
|
||
}
|
||
|
||
return styles;
|
||
}
|
||
|
||
// ── ENC (S-57 chart) style — dispatch by canonical aid_type ──────────────
|
||
// resolution is in m/pixel (OL passes it). Labels only show when zoomed in.
|
||
const ENC_LABEL_RES = 2.5; // ≈ zoom 16; closer than that → show name
|
||
function encStyle(feature, resolution) {
|
||
if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
|
||
const layer = (feature.get('layer') || '').toUpperCase();
|
||
const aidType = feature.get('aid_type') || 'UNKNOWN';
|
||
const showName = resolution != null && resolution < ENC_LABEL_RES;
|
||
const name = showName ? (feature.get('name') || feature.get('nombre') || '') : '';
|
||
const colours = feature.get('colours') || [];
|
||
const boyshp = feature.get('boyshp');
|
||
const catlam = feature.get('catlam');
|
||
const region = feature.get('cell_region') || window.IALA_REGION || 'B';
|
||
|
||
const isBcn = layer.startsWith('BCN');
|
||
const hasLight = !!(feature.get('light_desc'));
|
||
const lightDesc = showName ? (feature.get('light_desc') || '') : '';
|
||
// Compose on-map label: name on first line, light character on second
|
||
const labelParts = [name, lightDesc].filter(Boolean);
|
||
const label = labelParts.join('\n');
|
||
|
||
const ialaB = (region || 'B') === 'B';
|
||
const hasOrient = feature.get('orient') != null; // feature defines a leading line bearing
|
||
let canvas;
|
||
|
||
// ── Range / Leading mark — dayboard symbol when ORIENT is present ────────
|
||
// Only for LIGHTS and LNDMRK features (not buoys/beacons that may have
|
||
// inherited orient from a nearby light via the proximity merge).
|
||
const isRangeMark = hasOrient
|
||
&& aidType !== 'LEADING_LINE'
|
||
&& !layer.startsWith('BOY')
|
||
&& !layer.startsWith('BCN');
|
||
if (isRangeMark) {
|
||
canvas = _cachedIcon(`rangemark_${colours[0]}_${hasLight}`,
|
||
() => _encRangeDaymarkCanvas(colours, hasLight));
|
||
// Switch is skipped below (canvas already set)
|
||
}
|
||
|
||
// Helper: add light flare to one of the fixed IALA canvas symbols
|
||
const _withFlare = (baseFn, flareY, lightCss) => {
|
||
const base = baseFn();
|
||
if (!hasLight) return base;
|
||
const ct = base.getContext('2d');
|
||
_drawLightFlare(ct, base.width / 2, flareY, lightCss || '#ffffff');
|
||
return base;
|
||
};
|
||
const _lightCss = colours[0] ? _s57css(colours[0]) : '#ffffff';
|
||
|
||
// Dispatch by aid_type (skipped when isRangeMark already set canvas above).
|
||
if (!canvas) switch (aidType) {
|
||
// ── Lateral buoys — route through _encBuoyCanvas (BOYSHP-aware, 3-D) ──
|
||
case 'LATERAL_PORT':
|
||
case 'LATERAL_PREF_STBD':
|
||
case 'LATERAL_UNKNOWN': {
|
||
if (isBcn) {
|
||
canvas = _cachedIcon(`bcn_port_${colours.join('-')}_${region}`,
|
||
() => _encBeaconCanvas(colours, catlam, region));
|
||
} else {
|
||
const shp = boyshp || 2; // default: can for port
|
||
canvas = _cachedIcon(`boy_port_${colours.join('-')}_${shp}_${region}_${hasLight}`,
|
||
() => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight }));
|
||
}
|
||
break;
|
||
}
|
||
case 'LATERAL_STBD':
|
||
case 'LATERAL_PREF_PORT': {
|
||
if (isBcn) {
|
||
canvas = _cachedIcon(`bcn_stbd_${colours.join('-')}_${region}`,
|
||
() => _encBeaconCanvas(colours, catlam, region));
|
||
} else {
|
||
const shp = boyshp || 1; // default: conical for starboard
|
||
canvas = _cachedIcon(`boy_stbd_${colours.join('-')}_${shp}_${region}_${hasLight}`,
|
||
() => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight }));
|
||
}
|
||
break;
|
||
}
|
||
// ── Cardinal buoys ──────────────────────────────────────────────────────
|
||
case 'CARDINAL_N':
|
||
case 'CARDINAL_E':
|
||
case 'CARDINAL_S':
|
||
case 'CARDINAL_W': {
|
||
const q = aidType.split('_')[1];
|
||
canvas = _cachedIcon(`iala_car_${q}_${hasLight}`,
|
||
() => _withFlare(() => _ialaBoyCar(q), 2 * _IALA_S, _lightCss));
|
||
break;
|
||
}
|
||
case 'CARDINAL_UNKNOWN':
|
||
canvas = _cachedIcon(`iala_car_unk_${hasLight}`,
|
||
() => _withFlare(() => _ialaBoyCar('N'), 2 * _IALA_S, _lightCss));
|
||
break;
|
||
// ── Other buoy types ────────────────────────────────────────────────────
|
||
case 'ISOLATED_DANGER':
|
||
canvas = _cachedIcon(`iala_isd_${hasLight}`,
|
||
() => _withFlare(() => _ialaBoyIsd(), 2 * _IALA_S, '#cc0000'));
|
||
break;
|
||
case 'SAFE_WATER':
|
||
canvas = _cachedIcon(`iala_saw_${hasLight}`,
|
||
() => _withFlare(() => _ialaBoySaw(), 2 * _IALA_S, '#cc0000'));
|
||
break;
|
||
case 'SPECIAL': {
|
||
// BOYSPEC/BOYSPP — route through _encBuoyCanvas with sphere or special shape
|
||
const shp = boyshp || 3;
|
||
canvas = _cachedIcon(`boy_spp_${colours.join('-')}_${shp}_${hasLight}`,
|
||
() => _encBuoyCanvas({ colours: colours.length ? colours : [6], boyshp: shp,
|
||
catlam: null, region, hasLight }));
|
||
break;
|
||
}
|
||
case 'LEADING_LINE':
|
||
// Leading line — no canvas icon needed; pure line style handled below
|
||
return _ldlineStyle(feature);
|
||
case 'LIGHT_POINT':
|
||
canvas = _cachedIcon(`enc_light_${colours[0]}_${region}`,
|
||
() => _encLightCanvas(colours, region));
|
||
break;
|
||
// ── Generic / fallback ──────────────────────────────────────────────────
|
||
case 'LANDMARK':
|
||
case 'RACON':
|
||
case 'BEACON_GENERIC': {
|
||
canvas = _cachedIcon(`bcn_gen_${colours.join('-')}_${region}_${hasLight}`,
|
||
() => _encBeaconCanvas(colours, catlam, region));
|
||
break;
|
||
}
|
||
case 'BUOY_GENERIC':
|
||
default: {
|
||
// Always route through _encBuoyCanvas so every buoy gets 3-D symbology.
|
||
// boyshp defaults to 4 (pillar) when unknown — visible & recognisable.
|
||
const shp = boyshp || 4;
|
||
canvas = _cachedIcon(`boy_gen_${colours.join('-')}_${shp}_${hasLight}`,
|
||
() => _encBuoyCanvas({ colours, boyshp: shp, catlam, region, hasLight }));
|
||
}
|
||
}
|
||
|
||
// Safety net: if any draw function threw, canvas may be null. Fall back to
|
||
// a small colored circle so the feature is still visible and clickable.
|
||
if (!canvas) {
|
||
const fc = document.createElement('canvas'); fc.width = fc.height = 16;
|
||
const fx = fc.getContext('2d');
|
||
fx.beginPath(); fx.arc(8, 8, 7, 0, Math.PI * 2);
|
||
fx.fillStyle = colours[0] ? _s57css(colours[0]) : '#607d8b'; fx.fill();
|
||
fx.strokeStyle = '#fff'; fx.lineWidth = 1.5; fx.stroke();
|
||
canvas = fc;
|
||
console.warn('[ENC] canvas fallback for', layer, aidType, colours);
|
||
}
|
||
|
||
// Compute zoom from resolution (deterministic, no map object access needed).
|
||
// OL WebMercator: resolution ≈ 156543 / 2^zoom → zoom = log2(156543/res)
|
||
const _zr = resolution ? Math.log2(156543.03392 / Math.max(resolution, 0.001)) : 14;
|
||
// Scale: 0.29 at zoom 7, 0.65 at zoom 14+ — ~30 % larger than before.
|
||
const iconScale = Math.max(0.29, Math.min(0.65, 0.29 + (_zr - 7) * 0.052));
|
||
|
||
return new ol.style.Style({
|
||
image: new ol.style.Icon({
|
||
img: canvas, imgSize: [canvas.width, canvas.height], scale: iconScale,
|
||
anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
|
||
}),
|
||
text: label ? new ol.style.Text({
|
||
text: label,
|
||
offsetY: 10,
|
||
font: '600 10px "Inter", "Segoe UI", sans-serif',
|
||
fill: new ol.style.Fill({ color: '#e8f4fd' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(0,0,0,0.55)', width: 2 }),
|
||
overflow: true,
|
||
}) : undefined,
|
||
});
|
||
}
|
||
|
||
// ── Bathymetry rendering — IHO S-52 / NOAA convention ────────────────────
|
||
// Deeper water = paler / nearly white. Shallower = darker blue. Very shallow
|
||
// (<2 m, "drying heights") gets a warm tint to scream "danger".
|
||
function _depthFill(d) {
|
||
// Tones taken from IHO INT 1 / NOAA paper charts: a soft "blue tint"
|
||
// that gets paler with depth and turns to white below the safety contour.
|
||
if (d == null) return 'rgba(216,234,244,0.85)'; // unknown
|
||
if (d <= 1) return 'rgba(176,216,232,0.85)'; // ≤ 1 m NOAA shallow tint
|
||
if (d < 2) return 'rgba(196,226,238,0.85)'; // 1 – 2 m
|
||
if (d < 4) return 'rgba(216,234,244,0.85)'; // 2 – 4 m
|
||
if (d < 10) return 'rgba(232,242,248,0.85)'; // 4 – 10 m
|
||
return 'rgba(248,252,254,0.80)'; // ≥ 10 m near white
|
||
}
|
||
|
||
const _depthStyleCache = new Map();
|
||
function _depareStyle(depthMin) {
|
||
const key = `dp_${depthMin}`;
|
||
if (!_depthStyleCache.has(key)) {
|
||
_depthStyleCache.set(key, new ol.style.Style({
|
||
fill: new ol.style.Fill({ color: _depthFill(depthMin) }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(80,140,190,0.35)', width: 0.5 }),
|
||
}));
|
||
}
|
||
return _depthStyleCache.get(key);
|
||
}
|
||
|
||
const _contourLine = new ol.style.Style({
|
||
stroke: new ol.style.Stroke({ color: 'rgba(40,80,130,0.55)', width: 0.7 }),
|
||
});
|
||
|
||
function _contourStyle(depth, resolution) {
|
||
// Depth label every ~600 px along the line, only at zoom ≥ 14 (res < 10 m/px)
|
||
if (depth == null || resolution > 10) return _contourLine;
|
||
return [
|
||
_contourLine,
|
||
new ol.style.Style({
|
||
text: new ol.style.Text({
|
||
text: depth.toString(),
|
||
font: '500 9px "Inter", sans-serif',
|
||
fill: new ol.style.Fill({ color: '#1a3a5c' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.85)', width: 2 }),
|
||
placement: 'line',
|
||
textBaseline: 'middle',
|
||
}),
|
||
}),
|
||
];
|
||
}
|
||
|
||
function _soundingStyle(depth, resolution) {
|
||
// Numeric soundings — visible at most working zoom levels.
|
||
if (depth == null || resolution > 30) return null;
|
||
return new ol.style.Style({
|
||
text: new ol.style.Text({
|
||
text: depth.toString(),
|
||
font: '500 10px "Inter", sans-serif',
|
||
fill: new ol.style.Fill({ color: '#1a3a5c' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 2.5 }),
|
||
}),
|
||
});
|
||
}
|
||
|
||
// Solid-buff land style — paper-chart yellow. Only rendered in CHART mode
|
||
// so the regular DEPTHS toggle doesn't accidentally cover the OSM basemap.
|
||
let _chartModeActive = false;
|
||
const _landMaskStyle = new ol.style.Style({
|
||
fill: new ol.style.Fill({ color: 'rgba(248,231,184,1)' }), // NOAA paper buff
|
||
stroke: new ol.style.Stroke({ color: 'rgba(140,115,70,0.7)', width: 0.6 }),
|
||
});
|
||
|
||
function depthStyle(feature, resolution) {
|
||
if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
|
||
const layer = feature.get('layer');
|
||
// DEPCNT contour lines always rendered — they're thin and don't cover OSM.
|
||
// DEPARE polygons and LANDMASK fill paint opaque areas → restricted to
|
||
// CHART mode where OSM is hidden.
|
||
if (layer === 'DEPCNT') return _contourStyle(feature.get('depth'), resolution);
|
||
if (layer === 'DEPARE') return _chartModeActive ? _depareStyle(feature.get('depth_min')) : null;
|
||
if (layer === 'LANDMASK') return _chartModeActive ? _landMaskStyle : null;
|
||
return null;
|
||
}
|
||
|
||
// SOUNDG soundings — rendered on top of land/buildings/aids (zIndex 8).
|
||
function soundStyle(feature, resolution) {
|
||
if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
|
||
return _soundingStyle(feature.get('depth'), resolution);
|
||
}
|
||
|
||
// ── Land / terrain styles ─────────────────────────────────────────────────────
|
||
const _LNDARE_STYLE = new ol.style.Style({
|
||
fill: new ol.style.Fill({ color: 'rgba(245,231,174,0.97)' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(140,115,70,0.5)', width: 0.5 }),
|
||
});
|
||
const _BUAARE_STYLE = new ol.style.Style({
|
||
fill: new ol.style.Fill({ color: 'rgba(215,200,160,0.97)' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(120,100,60,0.5)', width: 0.5 }),
|
||
});
|
||
const _COALNE_STYLE = new ol.style.Style({
|
||
stroke: new ol.style.Stroke({ color: '#5a3e2b', width: 1.5 }),
|
||
});
|
||
// Buildings — S-52 LANDA tone with visible outline; name shown when zoomed in
|
||
function _buisglStyle(feature, resolution) {
|
||
const name = resolution < 3 ? (feature.get('name') || '') : '';
|
||
return new ol.style.Style({
|
||
fill: new ol.style.Fill({ color: 'rgba(188,160,110,0.92)' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(100,75,30,0.9)', width: 1.2 }),
|
||
text: name ? new ol.style.Text({
|
||
text: name,
|
||
font: '500 9px "Inter", sans-serif',
|
||
fill: new ol.style.Fill({ color: '#3e2a06' }),
|
||
stroke: new ol.style.Stroke({ color: 'rgba(255,240,200,0.85)', width: 2 }),
|
||
overflow: true,
|
||
}) : undefined,
|
||
});
|
||
}
|
||
|
||
function _landPointCanvas(key, drawFn) {
|
||
return _cachedIcon(key, drawFn);
|
||
}
|
||
|
||
function landStyle(feature, resolution) {
|
||
if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
|
||
const layer = feature.get('layer');
|
||
if (layer === 'LNDARE') return _LNDARE_STYLE;
|
||
if (layer === 'BUAARE') return _BUAARE_STYLE;
|
||
if (layer === 'COALNE') return _COALNE_STYLE;
|
||
if (layer === 'BUISGL') return _buisglStyle(feature, resolution);
|
||
if (layer === 'BRIDGE') {
|
||
const c = _landPointCanvas('lnd_bridge', () => {
|
||
const cv = document.createElement('canvas'); cv.width = 16; cv.height = 10;
|
||
const x = cv.getContext('2d');
|
||
x.fillStyle = '#555'; x.fillRect(0, 3, 16, 4);
|
||
x.strokeStyle = '#333'; x.lineWidth = 1; x.strokeRect(0, 3, 16, 4);
|
||
return cv;
|
||
});
|
||
return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [c.width, c.height] }) });
|
||
}
|
||
if (layer === 'HRBFAC') {
|
||
const c = _landPointCanvas('lnd_harbour', () => {
|
||
const cv = document.createElement('canvas'); cv.width = cv.height = 16;
|
||
const x = cv.getContext('2d');
|
||
x.strokeStyle = '#1a5276'; x.lineWidth = 1.5;
|
||
x.beginPath(); x.arc(8, 5, 3, 0, Math.PI * 2); x.stroke();
|
||
x.beginPath(); x.moveTo(8, 8); x.lineTo(8, 15); x.stroke();
|
||
x.beginPath(); x.moveTo(3, 12); x.lineTo(13, 12); x.stroke();
|
||
return cv;
|
||
});
|
||
return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [16, 16] }) });
|
||
}
|
||
if (layer === 'BUISGL' || layer === 'SILTNK') {
|
||
const c = _landPointCanvas(`lnd_${layer}`, () => {
|
||
const cv = document.createElement('canvas'); cv.width = cv.height = 10;
|
||
const x = cv.getContext('2d');
|
||
x.fillStyle = '#aaa'; x.fillRect(1, 1, 8, 8);
|
||
x.strokeStyle = '#666'; x.lineWidth = 1; x.strokeRect(1, 1, 8, 8);
|
||
return cv;
|
||
});
|
||
return new ol.style.Style({ image: new ol.style.Icon({ img: c, imgSize: [10, 10] }) });
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Hazard styles (IHO S-52 approximation) ────────────────────────────────────
|
||
const _WRECK_CANVAS = (() => {
|
||
const c = document.createElement('canvas'); c.width = c.height = 18;
|
||
const x = c.getContext('2d');
|
||
x.strokeStyle = '#7b0000'; x.lineWidth = 2;
|
||
x.beginPath(); x.moveTo(3, 3); x.lineTo(15, 15); x.stroke();
|
||
x.beginPath(); x.moveTo(15, 3); x.lineTo(3, 15); x.stroke();
|
||
x.beginPath(); x.arc(9, 9, 4, 0, Math.PI * 2);
|
||
x.strokeStyle = '#7b0000'; x.lineWidth = 1.5; x.stroke();
|
||
return c;
|
||
})();
|
||
const _OBSTRN_CANVAS = (() => {
|
||
const c = document.createElement('canvas'); c.width = c.height = 16;
|
||
const x = c.getContext('2d');
|
||
x.strokeStyle = 'rgb(140,0,140)'; x.lineWidth = 1.5;
|
||
x.beginPath(); x.arc(8, 8, 7, 0, Math.PI * 2); x.stroke();
|
||
x.fillStyle = 'rgb(140,0,140)';
|
||
x.beginPath(); x.arc(8, 8, 2.5, 0, Math.PI * 2); x.fill();
|
||
return c;
|
||
})();
|
||
const _UWTROC_CANVAS = (() => {
|
||
const c = document.createElement('canvas'); c.width = c.height = 16;
|
||
const x = c.getContext('2d');
|
||
x.beginPath(); x.moveTo(8, 1); x.lineTo(15, 8); x.lineTo(8, 15); x.lineTo(1, 8); x.closePath();
|
||
x.fillStyle = 'rgba(0,90,170,0.75)'; x.fill();
|
||
x.strokeStyle = '#003366'; x.lineWidth = 1; x.stroke();
|
||
return c;
|
||
})();
|
||
const _WRECK_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _WRECK_CANVAS, imgSize: [18, 18] }) });
|
||
const _OBSTRN_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _OBSTRN_CANVAS, imgSize: [16, 16] }) });
|
||
const _UWTROC_STYLE = new ol.style.Style({ image: new ol.style.Icon({ img: _UWTROC_CANVAS, imgSize: [16, 16] }) });
|
||
|
||
function hazardStyle(feature, resolution) {
|
||
if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
|
||
const layer = feature.get('layer');
|
||
if (layer === 'WRECKS') return _WRECK_STYLE;
|
||
if (layer === 'OBSTRN') return _OBSTRN_STYLE;
|
||
if (layer === 'UWTROC') return _UWTROC_STYLE;
|
||
return null;
|
||
}
|
||
|
||
// ── Zone / area styles ────────────────────────────────────────────────────────
|
||
const _ZONE_CFG = {
|
||
RESARE: { fill: 'rgba(200,0,0,0.07)', stroke: '#cc0000', dash: [6,4], w: 1.5 },
|
||
CTNARE: { fill: 'rgba(210,160,0,0.09)', stroke: '#b8860a', dash: [6,4], w: 1.5 },
|
||
ACHARE: { fill: 'rgba(0,70,180,0.07)', stroke: '#0044bb', dash: [5,5], w: 1.5 },
|
||
TSSLPT: { fill: 'rgba(0,130,0,0.07)', stroke: '#006600', dash: null, w: 1 },
|
||
FAIRWY: { fill: 'rgba(0,110,120,0.07)', stroke: '#005555', dash: [4,6], w: 1 },
|
||
PRCARE: { fill: 'rgba(190,90,0,0.07)', stroke: '#aa5500', dash: [6,4], w: 1.5 },
|
||
DMPGRD: { fill: 'rgba(100,100,100,0.07)', stroke: '#555555', dash: [3,5], w: 1 },
|
||
PIPARE: { fill: 'rgba(160,80,0,0.07)', stroke: '#884400', dash: [2,4], w: 1 },
|
||
};
|
||
const _zoneStyleMap = new Map();
|
||
Object.entries(_ZONE_CFG).forEach(([layer, cfg]) => {
|
||
_zoneStyleMap.set(layer, new ol.style.Style({
|
||
fill: new ol.style.Fill({ color: cfg.fill }),
|
||
stroke: new ol.style.Stroke({ color: cfg.stroke, width: cfg.w,
|
||
lineDash: cfg.dash || undefined }),
|
||
}));
|
||
});
|
||
|
||
function zoneStyle(feature, resolution) {
|
||
if (!_cellShouldRender(feature.get('cell'), resolution)) return null;
|
||
return _zoneStyleMap.get(feature.get('layer')) || null;
|
||
}
|
||
|
||
// Track the last bbox we requested so we can skip redundant fetches when the
|
||
// user pans only slightly. We expand the request bbox by a margin so small
|
||
// pans don't always trigger a refetch.
|
||
let _lastDepthBbox = null;
|
||
|
||
function _bboxContains(outer, inner) {
|
||
if (!outer || !inner) return false;
|
||
return outer[0] <= inner[0] && outer[1] <= inner[1]
|
||
&& outer[2] >= inner[2] && outer[3] >= inner[3];
|
||
}
|
||
|
||
window.loadChartDepths = async function() {
|
||
if (!depthLayer.getVisible()) return; // nothing to do if hidden
|
||
const view = map.getView();
|
||
const extent = view.calculateExtent(map.getSize());
|
||
// Convert from map projection (EPSG:3857) to lon/lat
|
||
const [w, s, e, n] = ol.proj.transformExtent(extent, view.getProjection(), 'EPSG:4326');
|
||
// Skip if current cache already covers the visible viewport
|
||
if (_bboxContains(_lastDepthBbox, [w, s, e, n])) return;
|
||
// Request a slightly larger area than visible so small pans hit the cache
|
||
const dx = (e - w) * 0.5, dy = (n - s) * 0.5;
|
||
const W = w - dx, S = s - dy, E = e + dx, N = n + dy;
|
||
try {
|
||
const r = await fetch(`/charts/depths?w=${W}&s=${S}&e=${E}&n=${N}`);
|
||
const fc = await r.json();
|
||
depthSource.clear();
|
||
soundSource.clear();
|
||
if (fc.features?.length) {
|
||
const fmt = new ol.format.GeoJSON();
|
||
const all = fmt.readFeatures(fc, { featureProjection: 'EPSG:3857' });
|
||
// Route SOUNDG (numbers) to soundLayer (zIndex 8) so soundings render
|
||
// above terrain. Everything else stays in depthLayer (zIndex 2).
|
||
const sound = [], rest = [];
|
||
for (const f of all) {
|
||
(f.get('layer') === 'SOUNDG' ? sound : rest).push(f);
|
||
}
|
||
if (rest.length) depthSource.addFeatures(rest);
|
||
if (sound.length) soundSource.addFeatures(sound);
|
||
// Update the legend's unit label from whatever the chart provides
|
||
const unit = fc.features[0].properties?.unit || 'METERS';
|
||
const legendTitle = document.querySelector('#depth-legend .dl-title');
|
||
if (legendTitle) legendTitle.textContent = `DEPTHS — ${unit}`;
|
||
}
|
||
_lastDepthBbox = [W, S, E, N];
|
||
console.log(`[ENC] Depths loaded: ${fc.features?.length || 0} in viewport`);
|
||
} catch (e) { console.warn('Depth load error', e); }
|
||
};
|
||
|
||
// Debounced reload on pan/zoom — fires ~300 ms after the user stops moving.
|
||
let _depthReloadTimer = null;
|
||
function _scheduleDepthReload() {
|
||
clearTimeout(_depthReloadTimer);
|
||
_depthReloadTimer = setTimeout(() => window.loadChartDepths(), 300);
|
||
}
|
||
|
||
window.loadChartLand = async function() {
|
||
landSource.clear();
|
||
try {
|
||
const r = await fetch('/charts/land');
|
||
const fc = await r.json();
|
||
if (!fc.features?.length) return;
|
||
landSource.addFeatures(
|
||
new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' }));
|
||
console.log(`[ENC] Land: ${fc.features.length} features`);
|
||
} catch(e) { console.warn('Land load error', e); }
|
||
};
|
||
|
||
window.loadChartHazards = async function() {
|
||
hazardSource.clear();
|
||
try {
|
||
const r = await fetch('/charts/hazards');
|
||
const fc = await r.json();
|
||
if (!fc.features?.length) return;
|
||
hazardSource.addFeatures(
|
||
new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' }));
|
||
console.log(`[ENC] Hazards: ${fc.features.length} features`);
|
||
} catch(e) { console.warn('Hazard load error', e); }
|
||
};
|
||
|
||
window.loadChartZones = async function() {
|
||
zoneSource.clear();
|
||
try {
|
||
const r = await fetch('/charts/zones');
|
||
const fc = await r.json();
|
||
if (!fc.features?.length) return;
|
||
zoneSource.addFeatures(
|
||
new ol.format.GeoJSON().readFeatures(fc, { featureProjection: 'EPSG:3857' }));
|
||
console.log(`[ENC] Zones: ${fc.features.length} features`);
|
||
} catch(e) { console.warn('Zones load error', e); }
|
||
};
|
||
|
||
window.reloadAllChartLayers = function() {
|
||
// Clear symbol cache so newly-built charts pick up fresh symbols
|
||
Object.keys(_iconCache).forEach(k => delete _iconCache[k]);
|
||
window.loadChartFeatures?.();
|
||
window.loadChartLand?.();
|
||
window.loadChartHazards?.();
|
||
window.loadChartZones?.();
|
||
if (depthLayer.getVisible()) {
|
||
_lastDepthBbox = null;
|
||
window.loadChartDepths?.();
|
||
}
|
||
};
|
||
|
||
window.loadChartFeatures = async function() {
|
||
encSource.clear();
|
||
try {
|
||
const r = await fetch('/charts/features');
|
||
const fc = await r.json();
|
||
if (!fc.features?.length) return;
|
||
|
||
// ── Merge LIGHTS into nearby structure features ────────────────────────
|
||
// S-57 encodes each buoy/beacon and its light as TWO separate objects.
|
||
// They may be at exactly the same coordinates (NOAA pattern) or up to
|
||
// ~50 m apart (≈0.00045°). We try exact match first, then proximity.
|
||
// After merging: light attrs copied into host, LIGHT_POINT dropped.
|
||
const MERGE_DEG = 0.00045; // ~50 m — matches backend proximity radius
|
||
const structList = []; // [{ f, x, y }] — all structure features
|
||
for (const f of fc.features) {
|
||
const lyr = (f.properties?.layer || '').toUpperCase();
|
||
if (lyr.startsWith('BOY') || lyr.startsWith('BCN') || lyr === 'LNDMRK') {
|
||
const [x, y] = f.geometry.coordinates;
|
||
structList.push({ f, x, y });
|
||
}
|
||
}
|
||
// Fast exact-coord lookup (grid key rounded to 6 dp ≈ 0.11 m)
|
||
const structByKey = new Map();
|
||
for (const s of structList) {
|
||
structByKey.set(`${s.x.toFixed(6)}_${s.y.toFixed(6)}`, s.f);
|
||
}
|
||
|
||
const before = fc.features.length;
|
||
fc.features = fc.features.filter(f => {
|
||
if (f.properties?.aid_type !== 'LIGHT_POINT') return true;
|
||
const [lx, ly] = f.geometry.coordinates;
|
||
|
||
// 1) Try exact coordinate match
|
||
let host = structByKey.get(`${lx.toFixed(6)}_${ly.toFixed(6)}`);
|
||
|
||
// 2) Proximity fallback (~50 m radius)
|
||
if (!host) {
|
||
let bestDist = MERGE_DEG;
|
||
for (const s of structList) {
|
||
const d = Math.sqrt((lx - s.x) ** 2 + (ly - s.y) ** 2);
|
||
if (d < bestDist) { bestDist = d; host = s.f; }
|
||
}
|
||
}
|
||
|
||
if (!host) return true; // standalone light with no nearby structure — keep
|
||
|
||
const hp = host.properties;
|
||
const lp = f.properties;
|
||
|
||
// Backfill colour from light if host has none
|
||
if (!hp.colours?.length && lp.colours?.length) {
|
||
hp.colours = lp.colours.slice();
|
||
// Infer CATLAM from colour when missing
|
||
const region = hp.cell_region || 'B';
|
||
const ialaB = region === 'B';
|
||
const c0 = lp.colours[0];
|
||
if (!hp.catlam) {
|
||
if (c0 === 4) hp.catlam = ialaB ? 1 : 2;
|
||
else if (c0 === 3) hp.catlam = ialaB ? 2 : 1;
|
||
}
|
||
if (hp.catlam === 1) hp.aid_type = 'LATERAL_PORT';
|
||
else if (hp.catlam === 2) hp.aid_type = 'LATERAL_STBD';
|
||
}
|
||
// Copy light character, range, height → these appear in the tooltip
|
||
if (lp.light_desc && !hp.light_desc) hp.light_desc = lp.light_desc;
|
||
if (lp.range_nm && !hp.range_nm) hp.range_nm = lp.range_nm;
|
||
if (lp.height_m && !hp.height_m) hp.height_m = lp.height_m;
|
||
// Copy orient so the buoy/beacon can anchor its leading line correctly
|
||
if (lp.orient != null && hp.orient == null) hp.orient = lp.orient;
|
||
return false; // drop the now-redundant LIGHT_POINT
|
||
});
|
||
const dropped = before - fc.features.length;
|
||
|
||
// ── Merge LDLINE pairs (enfilaciones) ──────────────────────────────────
|
||
// Enfilaciones always work in PAIRS (or triples): two aligned marks on the
|
||
// SAME bearing define one leading line. We get one LDLINE per mark but must
|
||
// show only ONE continuous line projected from the outermost (seaward) mark.
|
||
// Group by rounded bearing (0.5° resolution) and cell, then keep the one
|
||
// line that spans from the outermost start to the farthest endpoint.
|
||
const ldByKey = new Map(); // `${cell}_${roundedBearing}` → [feature, ...]
|
||
for (const f of fc.features) {
|
||
if (f.properties?.layer !== 'LDLINE') continue;
|
||
const bear = Math.round((f.properties.orient || 0) * 2) / 2;
|
||
const cell = f.properties.cell || '';
|
||
const key = `${cell}_${bear}`;
|
||
if (!ldByKey.has(key)) ldByKey.set(key, []);
|
||
ldByKey.get(key).push(f);
|
||
}
|
||
const ldRemove = new Set();
|
||
for (const [, group] of ldByKey) {
|
||
if (group.length <= 1) continue;
|
||
// Project each mark's start-point onto the bearing direction vector.
|
||
// The mark with SMALLEST projection is most seaward (outermost, front mark).
|
||
const bear0 = (group[0].properties.orient || 0) * Math.PI / 180;
|
||
const dx = Math.sin(bear0), dy = Math.cos(bear0);
|
||
const proj = group.map(f => {
|
||
const [x, y] = f.geometry.coordinates[0];
|
||
return { f, p: x * dx + y * dy, end: f.geometry.coordinates[1] };
|
||
});
|
||
proj.sort((a, b) => a.p - b.p);
|
||
// Keep the outermost (front mark), extend its endpoint to the farthest inland end
|
||
const keeper = proj[0].f;
|
||
// Farthest inland endpoint (highest projection value)
|
||
keeper.geometry.coordinates[1] = proj[proj.length - 1].end;
|
||
// Build the name from all paired marks combined
|
||
const names = [...new Set(proj.map(x => x.f.properties.name).filter(Boolean))];
|
||
if (names.length > 1) keeper.properties.name = names.join(' — ');
|
||
// Drop all others
|
||
for (const { f } of proj.slice(1)) ldRemove.add(f);
|
||
}
|
||
fc.features = fc.features.filter(f => !ldRemove.has(f));
|
||
|
||
const layerCounts = {};
|
||
fc.features.forEach(f => {
|
||
const l = f.properties?.layer || 'unknown';
|
||
layerCounts[l] = (layerCounts[l] || 0) + 1;
|
||
});
|
||
console.log(`[ENC] Features by layer (${dropped} duplicate lights dropped):`, layerCounts);
|
||
|
||
const fmt = new ol.format.GeoJSON();
|
||
const features = fmt.readFeatures(fc, { featureProjection: 'EPSG:3857' });
|
||
encSource.addFeatures(features);
|
||
} catch(e) { console.warn('ENC load error', e); }
|
||
};
|
||
window.loadChartFeatures();
|
||
window.loadChartLand();
|
||
window.loadChartHazards();
|
||
window.loadChartZones();
|
||
|
||
const map = new ol.Map({
|
||
target: 'map',
|
||
layers: [osmLayer, oceanRefLayer, depthLayer, landLayer, zoneLayer, seaMapLayer, encLayer, hazardLayer, soundLayer, vesselsLayer, aidsLayer],
|
||
view: new ol.View({
|
||
center: ol.proj.fromLonLat([MAP_CENTER_LON, MAP_CENTER_LAT]),
|
||
zoom: MAP_ZOOM,
|
||
minZoom: 5,
|
||
}),
|
||
controls: ol.control.defaults.defaults({ attribution: false }),
|
||
});
|
||
|
||
// ── Estilos ────────────────────────────────────────────────────────────────
|
||
|
||
// Color escala por LOA (matching AR ECDIS) — la longitud es el dato útil
|
||
// para evitar colisiones; el tipo AIS es categoría administrativa.
|
||
function vesselColorByLength(L) {
|
||
if (L >= 300) return '#ff3333'; // VLCC, ULCS
|
||
if (L >= 200) return '#ff8800'; // large container/cruise
|
||
if (L >= 100) return '#ffd700'; // medium
|
||
if (L >= 50) return '#3399ff'; // coastal
|
||
if (L >= 20) return '#99ddff'; // small craft
|
||
return '#aaffaa'; // yacht / fishing
|
||
}
|
||
|
||
// Compute ship silhouette polygon as ring of [lon,lat] coords.
|
||
// Reference point (AIS antenna) at the given (lat,lon).
|
||
// Bow points along heading (clockwise degrees from true north).
|
||
function _computeShipPolygon(lat, lon, headingDeg, length, beam,
|
||
toBow, toStern, toPort, toStarboard) {
|
||
const L = length || 50;
|
||
const B = beam || L * 0.15;
|
||
const Tb = toBow ?? L / 2;
|
||
const Ts = toStern ?? (L - Tb);
|
||
const Tp = toPort ?? B / 2;
|
||
const Tsb = toStarboard ?? (B - Tp);
|
||
|
||
// Ship-local frame (m): x=starboard, y=bow. Origin = antenna.
|
||
// Pointed bow, parallel sides, square stern with small notch.
|
||
const local = [
|
||
[0, Tb], // bow tip
|
||
[Tsb, Tb * 0.65], // starboard shoulder
|
||
[Tsb, -Ts * 0.50], // mid-starboard
|
||
[Tsb * 0.7, -Ts], // stern starboard corner
|
||
[0, -Ts * 0.92], // stern notch
|
||
[-Tp * 0.7, -Ts], // stern port corner
|
||
[-Tp, -Ts * 0.50], // mid-port
|
||
[-Tp, Tb * 0.65], // port shoulder
|
||
[0, Tb], // close
|
||
];
|
||
|
||
const h = headingDeg * Math.PI / 180;
|
||
const cos = Math.cos(h), sin = Math.sin(h);
|
||
const mPerDegLat = 111320;
|
||
const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180);
|
||
|
||
return local.map(([x, y]) => {
|
||
// Rotate clockwise from north: east = x*cos + y*sin, north = -x*sin + y*cos
|
||
const e = x * cos + y * sin;
|
||
const n = -x * sin + y * cos;
|
||
return [lon + e / mPerDegLon, lat + n / mPerDegLat];
|
||
});
|
||
}
|
||
|
||
function vesselStyle(feature, resolution) {
|
||
const heading = feature.get('heading') ?? feature.get('cog') ?? 0;
|
||
const length = feature.get('length') || 0;
|
||
const tipo = feature.get('tipo') || 0;
|
||
const color = length > 0 ? vesselColorByLength(length) : vesselColor(tipo);
|
||
const geom = feature.getGeometry();
|
||
const isPoly = geom && geom.getType() === 'Polygon';
|
||
|
||
const labelText = feature.get('nombre') || feature.get('mmsi');
|
||
const styles = [];
|
||
|
||
if (isPoly) {
|
||
// Pixel size of silhouette at current resolution — decides whether to
|
||
// add a fallback icon for very-zoomed-out views.
|
||
const ext = geom.getExtent();
|
||
const sizePx = Math.max(ext[2] - ext[0], ext[3] - ext[1]) / resolution;
|
||
|
||
styles.push(new ol.style.Style({
|
||
fill: new ol.style.Fill({ color }),
|
||
stroke: new ol.style.Stroke({ color: '#000', width: 1 }),
|
||
}));
|
||
|
||
// COG vector — dashed line ahead of the bow (1 minute of travel)
|
||
const sog = feature.get('sog') || 0;
|
||
if (sog > 0.5) {
|
||
const cog = feature.get('cog') ?? heading;
|
||
const lat = feature.get('lat'), lon = feature.get('lon');
|
||
const distM = sog * 0.514444 * 60; // 1 minute @ SOG knots
|
||
const h = cog * Math.PI / 180;
|
||
const mPerDegLat = 111320;
|
||
const mPerDegLon = 111320 * Math.cos(lat * Math.PI / 180);
|
||
const tipLon = lon + (distM * Math.sin(h)) / mPerDegLon;
|
||
const tipLat = lat + (distM * Math.cos(h)) / mPerDegLat;
|
||
const fromCoord = ol.proj.fromLonLat([lon, lat]);
|
||
const toCoord = ol.proj.fromLonLat([tipLon, tipLat]);
|
||
styles.push(new ol.style.Style({
|
||
geometry: new ol.geom.LineString([fromCoord, toCoord]),
|
||
stroke: new ol.style.Stroke({
|
||
color, width: 1.5, lineDash: [4, 4],
|
||
}),
|
||
}));
|
||
}
|
||
|
||
// If the silhouette is too small to perceive, stamp the legacy ship icon
|
||
// on the centroid so the vessel stays visible at world zoom.
|
||
if (sizePx < 6) {
|
||
const ctr = ol.extent.getCenter(ext);
|
||
const canvas = _cachedIcon(`ship_${color}`, () => _drawShip(color));
|
||
styles.push(new ol.style.Style({
|
||
geometry: new ol.geom.Point(ctr),
|
||
image: new ol.style.Icon({
|
||
img: canvas, imgSize: [canvas.width, canvas.height],
|
||
rotation: ol.math.toRadians(heading), rotateWithView: false,
|
||
}),
|
||
}));
|
||
}
|
||
|
||
styles.push(new ol.style.Style({
|
||
geometry: new ol.geom.Point(ol.extent.getCenter(ext)),
|
||
text: new ol.style.Text({
|
||
text: labelText,
|
||
offsetY: sizePx > 10 ? -(sizePx / 2) - 8 : 16,
|
||
font: '500 10px Inter, sans-serif',
|
||
fill: new ol.style.Fill({ color: '#e0ecf8' }),
|
||
stroke: new ol.style.Stroke({ color: '#030810', width: 3 }),
|
||
}),
|
||
}));
|
||
|
||
} else {
|
||
// Point feature — vessel reported position only, no dimensions yet.
|
||
const canvas = _cachedIcon(`ship_${color}`, () => _drawShip(color));
|
||
styles.push(new ol.style.Style({
|
||
image: new ol.style.Icon({
|
||
img: canvas, imgSize: [canvas.width, canvas.height],
|
||
rotation: ol.math.toRadians(heading), rotateWithView: false,
|
||
}),
|
||
text: new ol.style.Text({
|
||
text: labelText,
|
||
offsetY: 20,
|
||
font: '500 10px Inter, sans-serif',
|
||
fill: new ol.style.Fill({ color: '#e0ecf8' }),
|
||
stroke: new ol.style.Stroke({ color: '#030810', width: 3 }),
|
||
}),
|
||
}));
|
||
}
|
||
return styles;
|
||
}
|
||
|
||
function vesselColor(tipo) {
|
||
if (tipo >= 60 && tipo < 70) return '#06b6d4'; // pasajeros — cyan
|
||
if (tipo >= 70 && tipo < 80) return '#f59e0b'; // cargo — amber
|
||
if (tipo >= 80 && tipo < 90) return '#dc2626'; // tanquero — red
|
||
if (tipo === 52 || tipo === 21) return '#f97316'; // remolcador — orange
|
||
if (tipo === 30) return '#22c55e'; // pesquero — green
|
||
return '#94a3b8'; // default — slate
|
||
}
|
||
|
||
// ── Status dot: small alert overlay on top-right corner ─────────────────────
|
||
// Only painted when there's an anomaly so symbols stay clean in normal state.
|
||
// amber = fuera de posición red = en movimiento / alarma
|
||
function _stampStatus(canvas, enPos, enMov) {
|
||
if (!enMov && enPos !== false) return; // normal state → leave symbol clean
|
||
const ctx = canvas.getContext('2d');
|
||
const r = 2.5;
|
||
const x = canvas.width - r - 0.5, y = r + 0.5;
|
||
const dotColor = enMov ? '#ff3333' : '#fbbf24';
|
||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx.fillStyle = dotColor; ctx.fill();
|
||
ctx.strokeStyle = '#000'; ctx.lineWidth = 0.8; ctx.stroke();
|
||
}
|
||
|
||
// ── Position dot: small black dot at bottom-center = geographic anchor point ──
|
||
function _posDot(canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
const cx = canvas.width / 2;
|
||
ctx.beginPath(); ctx.arc(cx, canvas.height - 2, 2.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#111'; ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 0.8; ctx.stroke();
|
||
}
|
||
|
||
// Like _cachedIcon but always adds the position dot after drawing.
|
||
function _aidIcon(key, drawFn) {
|
||
if (!_iconCache[key]) {
|
||
const c = drawFn();
|
||
_posDot(c);
|
||
_iconCache[key] = c;
|
||
}
|
||
return _iconCache[key];
|
||
}
|
||
|
||
// ── Infer port/starboard from the aid's nombre ────────────────────────────────
|
||
function _isPortSide(nombre, ialaB) {
|
||
const n = (nombre || '').toLowerCase();
|
||
if (n.includes('babor') || n.includes('izquierda') || n.includes('larboard')) return true;
|
||
if (n.includes('estribor') || n.includes('derecha') || n.includes('starboard')) return false;
|
||
// Colour hints: IALA-B port=green, stbd=red; IALA-A inverted
|
||
if (ialaB) {
|
||
if (n.includes('verde') || n.includes('green')) return true;
|
||
if (n.includes('rojo') || n.includes('red')) return false;
|
||
} else {
|
||
if (n.includes('rojo') || n.includes('red')) return true;
|
||
if (n.includes('verde') || n.includes('green')) return false;
|
||
}
|
||
return true; // default to port when ambiguous
|
||
}
|
||
|
||
// ── Cardinal quadrant from name ───────────────────────────────────────────────
|
||
function _cardinalQ(nombre) {
|
||
const n = (nombre || '').toUpperCase();
|
||
if (n.includes('SUR') || n.includes('SOUTH')) return 'S';
|
||
if (n.includes('ESTE') || n.includes('EAST')) return 'E';
|
||
if (n.includes('OESTE') || n.includes('WEST') || n.includes('NW') || n.includes('SW')) {
|
||
if (n.includes('NW') || n.includes('NOROESTE')) return 'NW';
|
||
if (n.includes('SW') || n.includes('SUROESTE')) return 'SW';
|
||
return 'W';
|
||
}
|
||
if (n.includes('NE') || n.includes('NORESTE')) return 'NE';
|
||
if (n.includes('SE') || n.includes('SURESTE')) return 'SE';
|
||
return 'N';
|
||
}
|
||
|
||
function aidStyle(feature) {
|
||
const tipo = (feature.get('tipo') || '').toUpperCase();
|
||
const enPos = feature.get('en_posicion') !== false;
|
||
const enMov = feature.get('en_movimiento') === true;
|
||
const nombre = feature.get('nombre') || '';
|
||
const ialaB = window.IALA_REGION === 'B';
|
||
|
||
// S-57 colour codes: 3=Red 4=Green
|
||
const portCode = ialaB ? 4 : 3;
|
||
const stbdCode = ialaB ? 3 : 4;
|
||
const stat = enMov ? 'm' : enPos ? 'p' : 'o';
|
||
|
||
let canvas;
|
||
|
||
if (tipo === 'FARO') {
|
||
canvas = _aidIcon(`faro_${stat}`, () => {
|
||
const c = _drawLighthouse('#f9a825', 28);
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'FAROL') {
|
||
canvas = _aidIcon(`farol_${stat}`, () => {
|
||
const c = _drawLighthouse('#f9a825', 22);
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'BOYA_LATERAL') {
|
||
// Symbols ported from AR ECDIS — port=can, stbd=cone, colour by region.
|
||
const isPort = _isPortSide(nombre, ialaB);
|
||
const side = isPort ? 'P' : 'S';
|
||
canvas = _cachedIcon(`iala_lat_${side}_${ialaB}_${stat}`, () => {
|
||
const c = isPort ? _ialaBoyLatPort(ialaB) : _ialaBoyLatStbd(ialaB);
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'BOYA_CARDINAL') {
|
||
// Reduce intercardinals (NE/SE/NW/SW) to the 4 main quadrants.
|
||
let q = _cardinalQ(nombre);
|
||
if (!['N','S','E','W'].includes(q)) q = q[0];
|
||
canvas = _cachedIcon(`iala_car_${q}_${stat}`, () => {
|
||
const c = _ialaBoyCar(q);
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'BOYA_PELIGRO') {
|
||
canvas = _cachedIcon(`iala_isd_${stat}`, () => {
|
||
const c = _ialaBoyIsd();
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'BOYA_AGUAS_SEGURAS') {
|
||
canvas = _cachedIcon(`iala_saw_${stat}`, () => {
|
||
const c = _ialaBoySaw();
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'BOYA_ESPECIAL' || tipo.startsWith('BOYA')) {
|
||
canvas = _cachedIcon(`iala_spp_${stat}`, () => {
|
||
const c = _ialaBoySpp();
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'BALIZA') {
|
||
const isPort = _isPortSide(nombre, ialaB);
|
||
const catlam = isPort ? 1 : 2;
|
||
const col = isPort ? portCode : stbdCode;
|
||
const side = isPort ? 'P' : 'S';
|
||
canvas = _aidIcon(`baliza_lat_${side}_${ialaB}_${stat}`, () => {
|
||
const c = _encBeaconCanvas([col], catlam, ialaB ? 'B' : 'A');
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else if (tipo === 'ENFILACION') {
|
||
canvas = _aidIcon(`enfil_${stat}`, () => {
|
||
const c = _drawTriangle('#f9a825', '#c8780a', 22);
|
||
_stampStatus(c, enPos, enMov); return c;
|
||
});
|
||
|
||
} else {
|
||
const fill = enMov ? '#e53935' : (enPos ? '#1db954' : '#f59e0b');
|
||
const stroke = enMov ? '#ff6b6b' : (enPos ? '#4ade80' : '#fcd34d');
|
||
canvas = _aidIcon(`aid_gen_${stat}`, () => _drawDiamond(fill, stroke));
|
||
}
|
||
|
||
return new ol.style.Style({
|
||
image: new ol.style.Icon({
|
||
img: canvas, imgSize: [canvas.width, canvas.height], scale: 1,
|
||
anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction',
|
||
}),
|
||
text: new ol.style.Text({
|
||
text: nombre,
|
||
offsetY: 8,
|
||
font: '500 9px Inter, sans-serif',
|
||
fill: new ol.style.Fill({ color: '#cbd5e1' }),
|
||
stroke: new ol.style.Stroke({ color: '#030810', width: 3 }),
|
||
}),
|
||
});
|
||
}
|
||
|
||
// ── Actualizar features ────────────────────────────────────────────────────
|
||
|
||
window.updateVessel = function(data) {
|
||
let feature = vesselsSource.getFeatureById(data.mmsi);
|
||
const headingForGeom = data.heading ?? data.cog ?? 0;
|
||
|
||
// Build silhouette polygon if we have dimensions, fall back to Point.
|
||
let geometry;
|
||
if (data.length && data.beam) {
|
||
const ringLL = _computeShipPolygon(
|
||
data.lat, data.lon, headingForGeom,
|
||
data.length, data.beam,
|
||
data.to_bow, data.to_stern, data.to_port, data.to_starboard
|
||
);
|
||
const ring = ringLL.map(c => ol.proj.fromLonLat(c));
|
||
geometry = new ol.geom.Polygon([ring]);
|
||
} else {
|
||
geometry = new ol.geom.Point(ol.proj.fromLonLat([data.lon, data.lat]));
|
||
}
|
||
|
||
if (!feature) {
|
||
feature = new ol.Feature({ geometry });
|
||
feature.setId(data.mmsi);
|
||
vesselsSource.addFeature(feature);
|
||
} else {
|
||
feature.setGeometry(geometry);
|
||
}
|
||
|
||
feature.setProperties({
|
||
featureType: 'vessel',
|
||
mmsi: data.mmsi, nombre: data.nombre,
|
||
tipo: data.tipo, tipo_nombre: data.tipo_nombre,
|
||
sog: data.sog, cog: data.cog, heading: data.heading,
|
||
lat: data.lat, lon: data.lon,
|
||
length: data.length, beam: data.beam,
|
||
to_bow: data.to_bow, to_stern: data.to_stern,
|
||
to_port: data.to_port, to_starboard: data.to_starboard,
|
||
// Static AIS Type 5 / Type 24
|
||
imo: data.imo, callsign: data.callsign, bandera: data.bandera,
|
||
draught: data.draught, eta: data.eta,
|
||
// Dynamic Type 1/2/3
|
||
nav_status: data.nav_status, nav_status_name: data.nav_status_name,
|
||
rot: data.rot, fix_type: data.fix_type,
|
||
destino: data.destino, timestamp: data.timestamp,
|
||
});
|
||
// Pass the function so OL re-evaluates per resolution (silhouette ↔ icon).
|
||
feature.setStyle(vesselStyle);
|
||
recordPosition(data.mmsi, data.lat, data.lon);
|
||
|
||
const n = vesselsSource.getFeatures().length;
|
||
document.getElementById('vessel-count').textContent = n;
|
||
};
|
||
|
||
window.updateAid = function(data) {
|
||
let feature = aidsSource.getFeatureById(data.id);
|
||
const lat = data.lat_actual ?? data.lat_nominal;
|
||
const lon = data.lon_actual ?? data.lon_nominal;
|
||
const coord = ol.proj.fromLonLat([lon, lat]);
|
||
|
||
if (!feature) {
|
||
feature = new ol.Feature({ geometry: new ol.geom.Point(coord) });
|
||
feature.setId(data.id);
|
||
aidsSource.addFeature(feature);
|
||
} else {
|
||
feature.getGeometry().setCoordinates(coord);
|
||
}
|
||
|
||
feature.setProperties({ featureType: 'aid', ...data });
|
||
feature.setStyle(aidStyle(feature));
|
||
|
||
// Limpiar símbolo solo si volvió a posición Y no hay alerta activa registrada
|
||
if (data.en_posicion === true && !data.en_movimiento) {
|
||
const id = data.id;
|
||
const alerts = window.activeAlerts;
|
||
const hasAlert = alerts &&
|
||
(alerts.has(`ALERTA_ROJA__${id}`) || alerts.has(`ALERTA_AMARILLA__${id}`));
|
||
if (!hasAlert) window.setAidWarning?.(feature, null);
|
||
}
|
||
|
||
const n = aidsSource.getFeatures().length;
|
||
document.getElementById('aid-count').textContent = n;
|
||
};
|
||
|
||
// ── Tooltip ────────────────────────────────────────────────────────────────
|
||
|
||
const tooltip = document.getElementById('tooltip');
|
||
|
||
// Layers where hover should NOT pop a tooltip — land/depth areas cover huge
|
||
// regions and would fire on every mouse move over terrain.
|
||
// BUISGL (buildings) and point landmarks are intentionally NOT in this set.
|
||
const _NO_HOVER = new Set([
|
||
'SOUNDG', 'DEPCNT', 'DEPARE',
|
||
'LNDARE', 'LANDMASK', 'BUAARE', 'COALNE', // land — no tooltip
|
||
'LDLINE', // leading lines — no tooltip (bearing shown as label)
|
||
]);
|
||
|
||
map.on('pointermove', (evt) => {
|
||
// Skip depth features for hover detection. hitTolerance:8 so small
|
||
// silhouettes (e.g. a 300m vessel renders ~4px wide at zoom 11) are still
|
||
// hoverable without pixel-perfect aiming.
|
||
const feature = map.forEachFeatureAtPixel(evt.pixel,
|
||
f => _NO_HOVER.has(f.get('layer')) ? null : f,
|
||
{ hitTolerance: 8 });
|
||
if (!feature) {
|
||
tooltip.classList.add('hidden');
|
||
map.getTargetElement().style.cursor = '';
|
||
return;
|
||
}
|
||
|
||
const p = feature.getProperties();
|
||
tooltip.classList.remove('hidden');
|
||
tooltip.style.left = (evt.originalEvent.clientX + 14) + 'px';
|
||
tooltip.style.top = (evt.originalEvent.clientY - 10) + 'px';
|
||
map.getTargetElement().style.cursor = 'pointer';
|
||
|
||
if (p.featureType === 'vessel') {
|
||
const dims = (p.length && p.beam) ? `${p.length} × ${p.beam} m` : '--';
|
||
tooltip.innerHTML = `
|
||
<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 ? ' — ' + p.callsign : ''}</div>
|
||
|
||
<div class="coords-block">
|
||
<div class="label">POSITION</div>
|
||
${latStr} ${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 — 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 & 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 || ''} — ${p.layer}${p.cell_region ? ' — 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'}
|
||
${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 ${p.lon_actual.toFixed(5)} W
|
||
</div>
|
||
<div class="field" style="margin-bottom:10px">
|
||
<div class="field-label">DISPLACEMENT — ${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 & 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} — ${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 ${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;
|