'use strict';
const WS_URL = `ws://${window.location.hostname}:5503/ws`;
let ws = null;
let alertCount = 0;
// ── Deduplicación: una entrada por ayuda+tipo mientras no se ACK ──────────
// key = `${tipo}__${aid_id}` → { itemEl, ackDone }
const activeAlerts = new Map();
// ── Sonido de alerta (Web Audio API, sin archivos externos) ───────────────
let audioCtx = null;
let beepInterval = null;
function getAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
function beep(freq = 880, duration = 0.12) {
try {
const ctx = getAudioCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.setValueAtTime(0.35, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
} catch {}
}
function startBeeping(isAlarm) {
stopBeeping();
beep(isAlarm ? 1100 : 780);
beepInterval = setInterval(() => beep(isAlarm ? 1100 : 780), isAlarm ? 1800 : 3000);
}
function stopBeeping() {
if (beepInterval) { clearInterval(beepInterval); beepInterval = null; }
}
// ── WebSocket ─────────────────────────────────────────────────────────────
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
const el = document.getElementById('ws-status');
el.textContent = 'ONLINE';
el.className = 'link-indicator online';
};
ws.onclose = () => {
const el = document.getElementById('ws-status');
el.textContent = 'OFFLINE';
el.className = 'link-indicator offline';
setTimeout(connect, 3000);
};
ws.onerror = () => ws.close();
ws.onmessage = (evt) => {
try { handleMessage(JSON.parse(evt.data)); }
catch(e) { console.error('WS parse error', e); }
};
}
// Live ATON telemetry store: mmsi → data object
window.atonData = {};
function handleMessage(msg) {
switch (msg.type) {
case 'init':
(msg.aids || []).forEach(window.updateAid);
(msg.vessels || []).forEach(window.updateVessel);
(msg.atons || []).forEach(updateAton);
break;
case 'vessel': window.updateVessel(msg); break;
case 'aid_position': window.updateAid(msg); break;
case 'alert': addEvent(msg); break;
case 'gps': updateGpsStatus(msg); break;
case 'aton': updateAton(msg); break;
}
}
function updateAton(data) {
if (!data.mmsi) return;
window.atonData[data.mmsi] = data;
// If this ATON's panel is currently open, refresh it
const panel = document.getElementById('info-content');
if (panel && panel.dataset.mmsi === data.mmsi) {
// Find the aid entry that matches this MMSI and re-render
const feat = window.aidsSource?.getFeatures()
.find(f => f.get('mmsi') === data.mmsi);
if (feat) window.refreshInfoPanel?.(feat.getProperties());
}
// Trigger off-position alert if flagged
if (data.off_position) {
addEvent({
tipo: 'ALERTA_AMARILLA',
subtipo: 'ATON_FUERA_POSICION',
aid_id: data.mmsi,
desplazamiento_m: null,
timestamp: data.last_update,
});
}
if (data.battery_low) {
addEvent({
tipo: 'ALERTA_AMARILLA',
subtipo: 'ATON_BATERIA_BAJA',
aid_id: data.mmsi,
desplazamiento_m: null,
timestamp: data.last_update,
});
}
}
function addEvent(alert) {
const needsAck = ['ALERTA_ROJA','ALERTA_AMARILLA'].includes(alert.tipo);
const dedupeKey = needsAck ? `${alert.tipo}__${alert.aid_id}` : null;
// Si ya existe entrada para este par tipo+ayuda → solo actualizar detalle, nunca repetir
if (dedupeKey && activeAlerts.has(dedupeKey)) {
const entry = activeAlerts.get(dedupeKey);
const detailEl = entry.itemEl.querySelector('.ev-detail');
if (detailEl && alert.desplazamiento_m != null)
detailEl.innerHTML = `${alert.aid_id} — ${alert.desplazamiento_m} m from nominal`;
return;
}
alertCount++;
const badge = document.getElementById('alert-badge');
badge.textContent = alertCount;
badge.classList.remove('hidden');
let cls = 'ev-blue', typeLabel = '', detail = '';
if (alert.tipo === 'ALERTA_ROJA') {
cls = 'ev-red'; typeLabel = 'CRITICAL — MOVEMENT DETECTED';
detail = `Aid: ${alert.aid_id}`;
} else if (alert.tipo === 'ALERTA_AMARILLA') {
cls = 'ev-yellow'; typeLabel = 'WARNING — DISPLACED';
detail = `${alert.aid_id} — ${alert.desplazamiento_m} m from nominal`;
} else if (alert.tipo === 'GRABACION_INICIADA') {
cls = 'ev-red'; typeLabel = 'REC STARTED';
detail = `${alert.mmsi} → ${alert.aid_nombre}
${alert.distancia_m} m — ${alert.trigger}`;
} else if (alert.tipo === 'GRABACION_FINALIZADA') {
cls = 'ev-blue'; typeLabel = 'REC CLOSED';
detail = `${alert.mmsi} — Min dist: ${alert.distancia_min_m} m`;
}
const time = new Date().toUTCString().slice(17, 25);
const aidId = alert.aid_id || null;
const item = document.createElement('div');
item.className = `event-item ${cls}`;
// Persist the raw alert on the DOM so the REPORT modal can read it later.
// Stable id: prefer backend-emitted uuid, fall back to client-generated.
const alertId = alert.id || `cli-${Date.now()}-${Math.random().toString(36).slice(2,8)}`;
item.dataset.alertId = alertId;
item.dataset.alertJson = JSON.stringify({...alert, id: alertId});
item.innerHTML = `