244 lines
8.3 KiB
JavaScript
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> → ${alert.aid_nombre}<br>${alert.distancia_m} m — ${alert.trigger}`;
|
|
} else if (alert.tipo === 'GRABACION_FINALIZADA') {
|
|
cls = 'ev-blue'; typeLabel = 'REC CLOSED';
|
|
detail = `<span class="ev-mmsi">${alert.mmsi}</span> — 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();
|