Files
AidsMonitoring/frontend/js/websocket.js
T

244 lines
8.3 KiB
JavaScript

'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 = `<span class="ev-mmsi">${alert.mmsi}</span> &rarr; ${alert.aid_nombre}<br>${alert.distancia_m} m &mdash; ${alert.trigger}`;
} else if (alert.tipo === 'GRABACION_FINALIZADA') {
cls = 'ev-blue'; typeLabel = 'REC CLOSED';
detail = `<span class="ev-mmsi">${alert.mmsi}</span> &mdash; 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 = `
<div class="ev-header">
<span class="ev-type">${typeLabel}</span>
<span class="ev-time">${time} UTC</span>
</div>
<div class="ev-detail">${detail}</div>
<div class="ev-actions">
${needsAck ? `<button class="ev-ack-btn">ACKNOWLEDGE</button>` : ''}
<button class="ev-report-btn" onclick="window.openReportModal(this.closest('.event-item'))">REPORT</button>
</div>
<div class="ev-reported hidden"></div>
`;
if (needsAck) {
// Símbolo en carta
const aidFeature = window.aidsSource?.getFeatureById(aidId);
if (aidFeature) {
const level = alert.tipo === 'ALERTA_ROJA' ? 'alarm' : 'warning';
window.setAidWarning?.(aidFeature, level);
}
// Pitido
startBeeping(alert.tipo === 'ALERTA_ROJA');
// Fila titila hasta ACK
item.classList.add('ev-blinking');
// Registrar en mapa de activos
const entry = { itemEl: item, ackDone: false };
if (dedupeKey) activeAlerts.set(dedupeKey, entry);
item.querySelector('.ev-ack-btn').addEventListener('click', function() {
const ts = new Date().toUTCString().slice(17, 25);
this.textContent = `ACK — ${ts}${window.Auth?.session?.nombre || '?'}`;
this.disabled = true;
this.classList.add('ev-ack-done');
entry.ackDone = true;
item.classList.remove('ev-blinking');
// Parar pitido si no quedan alarmas sin ACK
const anyPending = [...activeAlerts.values()].some(e => !e.ackDone);
if (!anyPending) stopBeeping();
// Si la condición ya se resolvió: limpiar entrada y símbolo en carta
const feat = window.aidsSource?.getFeatureById(aidId);
const resolved = feat && feat.get('en_posicion') === true && !feat.get('en_movimiento');
if (resolved) {
if (dedupeKey) activeAlerts.delete(dedupeKey);
window.setAidWarning?.(feat, null);
}
});
}
const list = document.getElementById('events-list');
list.insertBefore(item, list.firstChild);
while (list.children.length > 60) list.removeChild(list.lastChild);
}
window.activeAlerts = activeAlerts;
function updateGpsStatus(fix) {
const dot = document.getElementById('sb-dot-gps');
const lbl = document.getElementById('sb-gps');
if (fix.status === 'FIX' && fix.lat != null) {
if (dot) dot.className = 'sb-dot green';
if (lbl) {
const hdop = fix.hdop != null ? ` HDOP ${fix.hdop.toFixed(1)}` : '';
lbl.textContent = `GPS: ${fix.lat.toFixed(4)}, ${fix.lon.toFixed(4)}${hdop}`;
}
if (window.updateOwnShip) window.updateOwnShip(fix);
} else {
if (dot) dot.className = 'sb-dot yellow';
if (lbl) lbl.textContent = 'GPS: CONNECTED — NO FIX';
}
}
connect();