'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 = `
${typeLabel} ${time} UTC
${detail}
${needsAck ? `` : ''}
`; 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();