// --- AIS Decoder Plugin (server-side decode) --- const aisStatus = document.getElementById("ais-status"); const aisMessagesEl = document.getElementById("ais-messages"); const aisFilterInput = document.getElementById("ais-filter"); const aisClearBtn = document.getElementById("ais-clear-btn"); const aisBarOverlay = document.getElementById("ais-bar-overlay"); const aisChannelSummaryEl = document.getElementById("ais-channel-summary"); const aisVesselCountEl = document.getElementById("ais-vessel-count"); const aisLatestSeenEl = document.getElementById("ais-latest-seen"); const AIS_MAX_MESSAGES = 200; const AIS_BAR_WINDOW_MS = 15 * 60 * 1000; const AIS_DEFAULT_A_HZ = 161_975_000; const AIS_CHANNEL_SPACING_HZ = 50_000; let aisFilterText = ""; let aisMessageHistory = []; function formatAisMhz(freqHz) { return `${(freqHz / 1_000_000).toFixed(3)} MHz`; } function currentAisChannelPlan() { const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ; const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ; return { aHz: safeAHz, bHz: safeAHz + AIS_CHANNEL_SPACING_HZ, }; } function aisChannelInfo(channel) { const plan = currentAisChannelPlan(); const ch = String(channel || "").trim().toUpperCase(); if (ch === "B") { return { label: "AIS-B", badgeClass: "ais-badge ais-badge-channel-b", freqText: formatAisMhz(plan.bHz), }; } return { label: "AIS-A", badgeClass: "ais-badge ais-badge-channel-a", freqText: formatAisMhz(plan.aHz), }; } function aisDisplayName(msg) { return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`; } function aisDisplayNameHtml(msg) { const label = escapeMapHtml(aisDisplayName(msg)); const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; if (!url) return label; return `${label}`; } function aisTypeLabel(type) { switch (Number(type)) { case 1: case 2: case 3: return "Class A Position"; case 4: return "Base Station"; case 5: return "Static/Voyage"; case 18: return "Class B Position"; case 19: return "Class B Extended"; case 21: return "Aid to Nav"; case 24: return "Class B Static"; default: return `Type ${type ?? "--"}`; } } function aisAgeText(tsMs) { if (!Number.isFinite(tsMs)) return "just now"; const deltaMs = Math.max(0, Date.now() - tsMs); const seconds = Math.round(deltaMs / 1000); if (seconds < 5) return "just now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); return `${hours}h ago`; } function aisMotionText(msg) { const parts = [ msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null, msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null, ].filter(Boolean); return parts.join(" · "); } function aisRouteText(msg) { return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); } function aisDistanceText(msg) { if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) { return ""; } const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; } function aisLatestByVessel(messages) { const byMmsi = new Map(); for (const msg of messages) { const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`; if (!byMmsi.has(key)) byMmsi.set(key, msg); } return Array.from(byMmsi.values()); } function updateAisSummary() { const plan = currentAisChannelPlan(); if (aisChannelSummaryEl) { aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; } const vessels = aisLatestByVessel(aisMessageHistory); if (aisVesselCountEl) { const count = vessels.length; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; } if (aisLatestSeenEl) { const latest = aisMessageHistory[0]; if (!latest) { aisLatestSeenEl.textContent = "No traffic yet"; } else { const channel = aisChannelInfo(latest.channel); aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`; } } } function renderAisRow(msg) { const row = document.createElement("div"); row.className = "ais-message"; const ts = msg._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", }); const name = aisDisplayName(msg); const nameHtml = aisDisplayNameHtml(msg); const channel = aisChannelInfo(msg.channel); const motion = aisMotionText(msg); const route = aisRouteText(msg); const distance = aisDistanceText(msg); const pos = msg.lat != null && msg.lon != null ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` : ""; row.dataset.filterText = [ name, msg.mmsi, msg.channel, channel.label, msg.vessel_name, msg.callsign, msg.destination, aisTypeLabel(msg.message_type), ] .filter(Boolean) .join(" ") .toUpperCase(); row.innerHTML = `