Initial commit — multi-tenant filtering, port constraints, chart bbox
This commit is contained in:
+305
-20
@@ -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 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,
|
||||
// 12000 m/px ≈ zoom 6 — layer visible from here; encStyle scales symbols
|
||||
// down smoothly so they shrink instead of blinking out as user zooms away.
|
||||
// Per-cell band ranges inside _cellShouldRender still refine visibility.
|
||||
maxResolution: 12000,
|
||||
// declutter: false — aids are physical objects that don't overlap in practice.
|
||||
// With declutter:true, ol.style.Icon gets hidden by collision but ol.style.Circle
|
||||
// (the position dot) does NOT participate in declutter, leaving ghost dots.
|
||||
declutter: false,
|
||||
});
|
||||
|
||||
// ── Bathymetry — split into 2 layers so SOUNDG (numbers) renders ON TOP of
|
||||
@@ -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);
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user