Initial commit — multi-tenant filtering, port constraints, chart bbox

This commit is contained in:
2026-05-04 22:41:09 -04:00
parent c3b07be67e
commit fcf1d2787a
1102 changed files with 7353 additions and 1166 deletions
+305 -20
View File
@@ -1,8 +1,8 @@
'use strict';
const MAP_CENTER_LAT = 25.7743;
const MAP_CENTER_LON = -80.1937;
const MAP_ZOOM = 11;
let MAP_CENTER_LAT = 11.0041; // Barranquilla — default for admin/superadmin
let MAP_CENTER_LON = -74.8070;
let MAP_ZOOM = 11;
// IALA region: 'A' = Europe/Africa/Asia (red=port), 'B' = Americas/Japan (green=port)
// Can be changed from settings; default B for Colombian Caribbean
@@ -43,11 +43,14 @@ 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 7below 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,
// 12000 m/px ≈ zoom 6layer visible from here; encStyle scales symbols
// down smoothly so they shrink instead of blinking out as user zooms away.
// Per-cell band ranges inside _cellShouldRender still refine visibility.
maxResolution: 12000,
// declutter: false — aids are physical objects that don't overlap in practice.
// With declutter:true, ol.style.Icon gets hidden by collision but ol.style.Circle
// (the position dot) does NOT participate in declutter, leaving ghost dots.
declutter: false,
});
// ── Bathymetry — split into 2 layers so SOUNDG (numbers) renders ON TOP of
@@ -131,6 +134,23 @@ function _cachedIcon(key, drawFn) {
return _iconCache[key];
}
// ── Soft-blur wrapper for aid canvases ────────────────────────────────────
// Applies a gentle gaussian blur to soften jagged canvas edges.
// Uses a WeakMap so each source canvas is blurred exactly ONCE — the result
// is reused on every subsequent style call (same cost as the cached original).
const _blurCache = new WeakMap();
function _softCanvas(src, blurPx = 0.7) {
if (_blurCache.has(src)) return _blurCache.get(src);
const dst = document.createElement('canvas');
dst.width = src.width;
dst.height = src.height;
const ctx = dst.getContext('2d');
ctx.filter = `blur(${blurPx}px)`;
ctx.drawImage(src, 0, 0);
_blurCache.set(src, dst);
return dst;
}
// ── Ship canvas ───────────────────────────────────────────────────────────
function _drawShip(color, sz = 32) {
const c = document.createElement('canvas');
@@ -798,6 +818,60 @@ function _encSppCanvas(sz = 40) {
return c;
}
// ── Special Beacon (BCNSPP) — poste + cuerpo amarillo + tope X ───────────
// Misma estructura que BCNLAT pero: cuerpo amarillo, topmark = cruz X amarilla.
function _encBcnSppCanvas(sz = 36) {
const c = _mkC(sz); const ctx = c.getContext('2d'); const cx = sz / 2;
const col = '#f9a825';
const groundY = sz * 0.92;
const poleTopY = sz * 0.04;
const poleW = sz * 0.055;
const bodyW = sz * 0.28;
const bodyTopY = sz * 0.48;
const bodyBotY = groundY;
const tmW = sz * 0.36;
const tmBotY = sz * 0.27;
const tmTopY = sz * 0.05;
const tmCY = (tmTopY + tmBotY) / 2;
const tmR = (tmBotY - tmTopY) * 0.38; // radio del X
// Sombra base
ctx.beginPath();
ctx.ellipse(cx, groundY, sz * 0.12, sz * 0.020, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.fill();
// Poste gris
const poleGrd = ctx.createLinearGradient(cx - poleW, 0, cx + poleW, 0);
poleGrd.addColorStop(0, '#999'); poleGrd.addColorStop(0.4, '#ddd'); poleGrd.addColorStop(1, '#777');
ctx.fillStyle = poleGrd;
ctx.fillRect(cx - poleW / 2, poleTopY, poleW, groundY - poleTopY);
// Cuerpo amarillo (gradiente 3D)
const bodyGrd = ctx.createLinearGradient(cx - bodyW / 2, 0, cx + bodyW / 2, 0);
bodyGrd.addColorStop(0, _lighten3D(col, 0.35));
bodyGrd.addColorStop(0.45, col);
bodyGrd.addColorStop(1, _darken3D(col, 0.28));
ctx.strokeStyle = 'rgba(0,0,0,0.65)'; ctx.lineWidth = 0.9;
ctx.fillStyle = bodyGrd;
ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
ctx.strokeRect(cx - bodyW / 2, bodyTopY, bodyW, bodyBotY - bodyTopY);
// Highlight lateral
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(cx - bodyW / 2, bodyTopY, bodyW * 0.20, bodyBotY - bodyTopY);
// Topmark X — contorno oscuro primero, luego amarillo encima
ctx.lineCap = 'round';
ctx.lineWidth = sz * 0.10; ctx.strokeStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke();
ctx.lineWidth = sz * 0.075; ctx.strokeStyle = col;
ctx.beginPath(); ctx.moveTo(cx - tmR, tmCY - tmR); ctx.lineTo(cx + tmR, tmCY + tmR); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx + tmR, tmCY - tmR); ctx.lineTo(cx - tmR, tmCY + tmR); ctx.stroke();
return c;
}
/* ═══════════════════════════════════════════════════════════════════════════
IALA buoy canvases — exact port of AR ECDIS SVG defs (ui/js/map.js).
Each function returns a canvas sized scale × SVG viewBox, with the
@@ -1394,6 +1468,10 @@ function _recomputeHiddenCells() {
for (const [cellId, meta] of Object.entries(cells)) {
if (!meta.bbox) continue;
// Custom / operator-installed cells (band 0) are NEVER hidden by any NOAA
// overview cell. They represent the primary operational chart for the port
// and must remain visible at all zoom levels.
if (meta.band === 0) continue;
for (const [otherId, other] of Object.entries(cells)) {
if (otherId === cellId || !other.bbox) continue;
if (other.band <= meta.band) continue;
@@ -1621,11 +1699,20 @@ function encStyle(feature, resolution) {
});
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 }));
// BOYSPP (boya flotante) → _encSppCanvas | BCNSPP (baliza fija) → _encBcnSppCanvas
if (isBcn) {
canvas = _cachedIcon(`bcn_spp_${hasLight}`, () => {
const cv = _encBcnSppCanvas(36);
if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825');
return cv;
});
} else {
canvas = _cachedIcon(`boy_spp_${hasLight}`, () => {
const cv = _encSppCanvas(48);
if (hasLight) _drawLightFlare(cv.getContext('2d'), cv.width / 2, cv.height * 0.08, '#f9a825');
return cv;
});
}
break;
}
case 'LEADING_LINE':
@@ -1668,22 +1755,37 @@ function encStyle(feature, resolution) {
// 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));
// Scale: 0.14 at zoom 6, grows to 0.52 at zoom 14+.
// Smooth curve — symbols shrink as user zooms out instead of vanishing.
const iconScale = Math.max(0.14, Math.min(0.52, 0.14 + (_zr - 6) * 0.0475));
return new ol.style.Style({
// Position dot — small circle rendered at the exact geographic coordinate.
// Anchor is [0.5, 1.0] so the dot sits precisely at the buoy's lat/lon.
const dotStyle = new ol.style.Style({
image: new ol.style.Circle({
radius: 2,
fill: new ol.style.Fill({ color: 'rgba(10,26,46,0.9)' }),
stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.9)', width: 0.8 }),
}),
});
const iconStyle = new ol.style.Style({
image: new ol.style.Icon({
img: canvas, imgSize: [canvas.width, canvas.height], scale: iconScale,
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: '500 9px "Inter", "Segoe UI", sans-serif',
fill: new ol.style.Fill({ color: '#e8f4fd' }),
offsetY: 8,
font: '600 9px "Inter", "Segoe UI", sans-serif',
fill: new ol.style.Fill({ color: '#0a1a2e' }),
stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.92)', width: 2.5 }),
overflow: true,
}) : undefined,
});
return [iconStyle, dotStyle];
}
// ── Bathymetry rendering — IHO S-52 / NOAA convention ────────────────────
@@ -2146,6 +2248,7 @@ 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],
@@ -3674,3 +3777,185 @@ setInterval(() => {
// Exponer aidsSource globalmente para el formulario
window.aidsSource = aidsSource;
// ── Port constraints for USER role ────────────────────────────────────────
// Called after login when the user has a company → port.
// ── Port constraint state ─────────────────────────────────────────────────────
let _portMinZoom = null; // minimum zoom for client users
let _portExtent = null; // [minX,minY,maxX,maxY] in EPSG:3857
let _constraintLock = false; // prevents re-entrant moveend feedback loop
// Single moveend listener — hard backstop for zoom + pan constraints.
// Lock prevents the corrective setCenter/setZoom from re-triggering the
// listener in a loop that would cause sluggishness.
map.on('moveend', function() {
if (_constraintLock) return;
if (_portMinZoom === null && !_portExtent) return;
const view = map.getView();
let newZoom = view.getZoom();
let newCenter = view.getCenter().slice();
let needsFix = false;
// Zoom floor
if (_portMinZoom !== null && newZoom < _portMinZoom) {
newZoom = _portMinZoom;
needsFix = true;
}
// Pan bounds — clamp center to chart extent
if (_portExtent) {
const cx = Math.max(_portExtent[0], Math.min(_portExtent[2], newCenter[0]));
const cy = Math.max(_portExtent[1], Math.min(_portExtent[3], newCenter[1]));
if (cx !== newCenter[0] || cy !== newCenter[1]) {
newCenter = [cx, cy];
needsFix = true;
}
}
if (needsFix) {
_constraintLock = true;
view.setCenter(newCenter);
view.setZoom(newZoom);
setTimeout(function() { _constraintLock = false; }, 150);
}
});
window._setPortConstraints = function(port) {
if (!port || port.center_lat == null) return;
_portMinZoom = port.default_zoom || 12;
// Extent: real ENC chart bbox preferred, fallback ±0.25° pad.
// chart_bbox = [west, south, east, north] WGS-84.
if (port.chart_bbox && port.chart_bbox.length === 4) {
const [west, south, east, north] = port.chart_bbox;
const sw = ol.proj.fromLonLat([west, south]);
const ne = ol.proj.fromLonLat([east, north]);
_portExtent = [sw[0], sw[1], ne[0], ne[1]];
} else {
const pad = 0.25;
const sw = ol.proj.fromLonLat([port.center_lon - pad, port.center_lat - pad]);
const ne = ol.proj.fromLonLat([port.center_lon + pad, port.center_lat + pad]);
_portExtent = [sw[0], sw[1], ne[0], ne[1]];
}
map.getView().setMinZoom(_portMinZoom);
};
window._clearPortConstraints = function() {
_portMinZoom = null;
_portExtent = null;
map.getView().setMinZoom(0);
};
// Returns true once the map view is usable.
window._mapReady = function() {
try { return !!map.getView(); } catch (_) { return false; }
};
// Call after the map container becomes visible (app shown after login).
// OL initializes with display:none → size=0×0. updateSize() fixes both
// the rendering size and re-anchors the center to the correct position.
window._mapUpdateSize = function() {
map.updateSize();
};
// Jump to position immediately — no animation (used for client role login).
window._mapJump = function(lon, lat, zoom) {
const view = map.getView();
view.setCenter(ol.proj.fromLonLat([lon, lat]));
view.setZoom(zoom);
};
// ── DVR Playback hooks ─────────────────────────────────────────────────────
// dvr.js calls these to move a marker and draw the track on the map.
(function() {
const dvrSource = new ol.source.Vector();
const dvrLayer = new ol.layer.Vector({
source: dvrSource,
zIndex: 50, // above everything
visible: false,
});
map.addLayer(dvrLayer);
// Feature IDs
const MARKER_ID = '__dvr_marker__';
const TRACK_ID = '__dvr_track__';
// ── Move playback marker ─────────────────────────────────────────────────
window._dvrMapMove = function(lon, lat, cog) {
dvrLayer.setVisible(true);
let feat = dvrSource.getFeatureById(MARKER_ID);
const coord = ol.proj.fromLonLat([lon, lat]);
if (!feat) {
feat = new ol.Feature({ geometry: new ol.geom.Point(coord) });
feat.setId(MARKER_ID);
feat.setStyle(new ol.style.Style({
image: new ol.style.RegularShape({
fill: new ol.style.Fill({ color: '#00e5ff' }),
stroke: new ol.style.Stroke({ color: '#003344', width: 1.5 }),
points: 3,
radius: 10,
rotation: ((cog || 0) * Math.PI) / 180,
rotateWithView: true,
}),
zIndex: 100,
}));
dvrSource.addFeature(feat);
} else {
feat.getGeometry().setCoordinates(coord);
// Update triangle rotation
const s = feat.getStyle();
if (s && s.getImage()) {
s.getImage().setRotation(((cog || 0) * Math.PI) / 180);
}
}
// Pan map to keep marker in view if it's near the edge
const view = map.getView();
const extent = view.calculateExtent(map.getSize());
if (!ol.extent.containsCoordinate(extent, coord)) {
view.animate({ center: coord, duration: 400 });
}
};
// ── Draw full track polyline ─────────────────────────────────────────────
window._dvrMapShowTrack = function(track) {
dvrLayer.setVisible(true);
// Remove existing track line
const existing = dvrSource.getFeatureById(TRACK_ID);
if (existing) dvrSource.removeFeature(existing);
if (!track || !track.length) return;
const coords = track
.filter(p => p.lat != null && p.lon != null)
.map(p => ol.proj.fromLonLat([p.lon, p.lat]));
if (coords.length < 2) return;
const line = new ol.Feature({
geometry: new ol.geom.LineString(coords),
});
line.setId(TRACK_ID);
line.setStyle(new ol.style.Style({
stroke: new ol.style.Stroke({
color: '#00e5ff',
width: 2,
lineDash: [6, 4],
}),
}));
dvrSource.addFeature(line);
// Fit view to track extent with padding
const ext = dvrSource.getExtent();
map.getView().fit(ext, { padding: [60, 60, 60, 60], duration: 800 });
};
// ── Clear DVR layer (called on stop) ────────────────────────────────────
window._dvrMapClear = function() {
dvrSource.clear();
dvrLayer.setVisible(false);
};
})();