feat: Add VDL2 mode and ACARS standalone frontend

Add VDL2 (VHF Digital Link Mode 2) decoding via dumpvdl2 as a new mode,
and promote ACARS from ADS-B-dashboard-only to a first-class standalone
mode in the main SPA. Both aviation datalink modes now have full nav
entries, sidebar partials with region-based frequency selectors, and
SSE streaming. VDL2 also integrated into the ADS-B dashboard as a
collapsible sidebar alongside ACARS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-16 19:48:10 +00:00
parent 126b9ba2ee
commit 2b3f351ff0
12 changed files with 1653 additions and 103 deletions

View File

@@ -92,8 +92,12 @@
<span class="strip-value" id="stripAcars">0</span>
<span class="strip-label">ACARS</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripVdl2">0</span>
<span class="strip-label">VDL2</span>
</div>
<div class="strip-stat source-stat" title="Data source (Local or Agent name)">
<span class="strip-value" id="stripSource" style="font-size: 10px;">Local</span>
<span class="strip-value" id="stripSource">Local</span>
<span class="strip-label">SOURCE</span>
</div>
<div class="strip-stat signal-stat" title="Signal quality (messages/errors)">
@@ -106,17 +110,25 @@
</div>
<div class="strip-divider"></div>
<button type="button" class="strip-btn" onclick="showSquawkInfo(null)" title="Squawk Code Reference">
📟 Squawk
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="14"/><line x1="10" y1="10" x2="10" y2="14"/><line x1="14" y1="10" x2="14" y2="14"/><line x1="18" y1="10" x2="18" y2="14"/></svg>
Squawk
</button>
<button type="button" class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
🔗 Lookup
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Lookup
</button>
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
📊 Report
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Report
</button>
<a class="strip-btn" href="/adsb/history" title="Open History Reporting">
📚 History
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
History
</a>
<button type="button" class="strip-btn" onclick="toggleAntennaGuide()" title="1090 MHz Antenna Guide">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12 L12 2 L22 12"/><line x1="12" y1="2" x2="12" y2="22"/><path d="M4.93 4.93 L12 12"/><path d="M19.07 4.93 L12 12"/></svg>
Antenna
</button>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot inactive" id="trackingDot"></div>
@@ -176,6 +188,55 @@
</button>
</div>
<!-- VDL2 Panel (left of map, after ACARS) - Collapsible -->
<div class="vdl2-sidebar" id="vdl2Sidebar">
<div class="vdl2-sidebar-content" id="vdl2SidebarContent">
<div class="panel vdl2-panel">
<div class="panel-header">
<span>VDL2 MESSAGES</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="vdl2Count" style="font-size: 10px; color: var(--accent-cyan);">0</span>
<div class="panel-indicator" id="vdl2PanelIndicator"></div>
</div>
</div>
<div id="vdl2PanelContent">
<div class="vdl2-info" style="font-size: 9px; color: var(--text-muted); padding: 5px 8px; border-bottom: 1px solid var(--border-color);">
<span style="color: var(--accent-yellow);">&#9888;</span> Requires separate SDR (VHF ~137 MHz)
</div>
<div class="vdl2-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select id="vdl2DeviceSelect" style="flex: 1; font-size: 10px;">
<option value="0">SDR 0</option>
<option value="1">SDR 1</option>
</select>
<select id="vdl2RegionDashSelect" onchange="updateVdl2FreqCheckboxes()" style="flex: 1; font-size: 10px;">
<option value="na">N. America</option>
<option value="eu">Europe</option>
<option value="ap">Asia-Pac</option>
</select>
</div>
<div id="vdl2FreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; font-size: 9px;">
<!-- Frequency checkboxes populated by JS -->
</div>
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="width: 100%;">
&#9654; START VDL2
</button>
</div>
<div class="vdl2-messages" id="vdl2Messages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
<div style="font-size: 10px; color: var(--text-muted);">No VDL2 messages</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start VDL2 to receive digital datalink messages</div>
</div>
</div>
</div>
</div>
</div>
<button class="vdl2-collapse-btn" id="vdl2CollapseBtn" onclick="toggleVdl2Sidebar()" title="Toggle VDL2 Panel">
<span id="vdl2CollapseIcon">&#9664;</span>
<span class="vdl2-collapse-label">VDL2</span>
</button>
</div>
<!-- Main Display (Map or Radar Scope) -->
<div class="main-display">
<div class="display-container">
@@ -223,88 +284,6 @@
</div>
</div>
</div>
<!-- Antenna Guide Panel -->
<div class="panel" id="antennaGuidePanel">
<div class="panel-header" style="cursor: pointer;" onclick="document.getElementById('antennaGuideContent').style.display = document.getElementById('antennaGuideContent').style.display === 'none' ? 'block' : 'none'; this.querySelector('.panel-toggle').textContent = document.getElementById('antennaGuideContent').style.display === 'none' ? '&#9654;' : '&#9660;';">
<span>ANTENNA GUIDE</span>
<span class="panel-toggle" style="font-size: 10px; color: var(--text-muted);">&#9654;</span>
</div>
<div id="antennaGuideContent" style="display: none; padding: 10px; font-size: 11px; color: var(--text-secondary); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
1090 MHz &mdash; stock SDR antenna can work but is not ideal
</p>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Stock Telescopic Antenna</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
<li><strong style="color: var(--text-primary);">Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: #00ff88; font-size: 11px;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
<li><strong style="color: var(--text-primary);">Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
<li><strong style="color: var(--text-primary);">Gain:</strong> ~5&ndash;7 dBi omnidirectional, ideal for 360&deg; coverage</li>
<li><strong style="color: var(--text-primary);">Range:</strong> 150&ndash;250+ NM depending on height and LOS</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Commercial Options</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
<li><strong style="color: var(--text-primary);">ADSBexchange whip:</strong> ~$40, similar performance</li>
<li><strong style="color: var(--text-primary);">Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Placement & LNA</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
<li><strong style="color: var(--text-primary);">Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
<li><strong style="color: var(--text-primary);">Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 4px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">ADS-B frequency</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">1090 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">6.9 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">PPM (pulse)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Bandwidth</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">~2 MHz</td>
</tr>
<tr>
<td style="padding: 2px 4px; color: var(--text-dim);">Typical range (outdoor)</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">100&ndash;250 NM</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Controls Bar - Reorganized -->
@@ -4115,6 +4094,211 @@ sudo make install</code>
});
});
// ============================================
// VDL2 DATALINK PANEL
// ============================================
let vdl2EventSource = null;
let isVdl2Running = false;
let vdl2MessageCount = 0;
let vdl2SidebarCollapsed = localStorage.getItem('vdl2SidebarCollapsed') !== 'false';
let vdl2Frequencies = {
'na': ['136975000', '136100000', '136650000', '136700000', '136800000'],
'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'],
'ap': ['136975000', '136900000']
};
let vdl2FreqLabels = {
'136975000': '136.975', '136100000': '136.100', '136650000': '136.650',
'136700000': '136.700', '136800000': '136.800', '136675000': '136.675',
'136725000': '136.725', '136775000': '136.775', '136825000': '136.825',
'136900000': '136.900'
};
function toggleVdl2Sidebar() {
const sidebar = document.getElementById('vdl2Sidebar');
vdl2SidebarCollapsed = !vdl2SidebarCollapsed;
sidebar.classList.toggle('collapsed', vdl2SidebarCollapsed);
localStorage.setItem('vdl2SidebarCollapsed', vdl2SidebarCollapsed);
}
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('vdl2Sidebar');
if (sidebar && vdl2SidebarCollapsed) {
sidebar.classList.add('collapsed');
}
updateVdl2FreqCheckboxes();
});
function updateVdl2FreqCheckboxes() {
const region = document.getElementById('vdl2RegionDashSelect').value;
const freqs = vdl2Frequencies[region] || vdl2Frequencies['na'];
const container = document.getElementById('vdl2FreqSelector');
const previouslyChecked = new Set();
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
container.innerHTML = freqs.map(freq => {
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
const label = vdl2FreqLabels[freq] || freq;
return `
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
<input type="checkbox" class="vdl2-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
<span>${label}</span>
</label>
`;
}).join('');
}
function getVdl2RegionFreqs() {
const checkboxes = document.querySelectorAll('.vdl2-freq-cb:checked');
const selectedFreqs = Array.from(checkboxes).map(cb => cb.value);
if (selectedFreqs.length === 0) {
const region = document.getElementById('vdl2RegionDashSelect').value;
return vdl2Frequencies[region] || vdl2Frequencies['na'];
}
return selectedFreqs;
}
function toggleVdl2() {
if (isVdl2Running) {
stopVdl2();
} else {
startVdl2();
}
}
function startVdl2() {
const vdl2Select = document.getElementById('vdl2DeviceSelect');
const device = vdl2Select.value;
const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
const frequencies = getVdl2RegionFreqs();
if (isTracking && device === '0') {
const useAnyway = confirm(
'Warning: ADS-B tracking may be using SDR device 0.\n\n' +
'VDL2 uses VHF frequencies (~137 MHz) while ADS-B uses 1090 MHz.\n' +
'You need TWO separate SDR devices to receive both simultaneously.\n\n' +
'Click OK to start VDL2 on device ' + device + ' anyway.'
);
if (!useAnyway) return;
}
fetch('/vdl2/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, frequencies, gain: '40', sdr_type })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isVdl2Running = true;
vdl2MessageCount = 0;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9632; STOP VDL2';
document.getElementById('vdl2ToggleBtn').classList.add('active');
document.getElementById('vdl2PanelIndicator').classList.add('active');
startVdl2Stream();
} else {
alert('VDL2 Error: ' + (data.message || 'Failed to start'));
}
})
.catch(err => alert('VDL2 Error: ' + err));
}
function stopVdl2() {
fetch('/vdl2/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isVdl2Running = false;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9654; START VDL2';
document.getElementById('vdl2ToggleBtn').classList.remove('active');
document.getElementById('vdl2PanelIndicator').classList.remove('active');
if (vdl2EventSource) {
vdl2EventSource.close();
vdl2EventSource = null;
}
});
}
function startVdl2Stream() {
if (vdl2EventSource) vdl2EventSource.close();
vdl2EventSource = new EventSource('/vdl2/stream');
vdl2EventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'vdl2') {
vdl2MessageCount++;
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
addVdl2Message(data);
}
};
vdl2EventSource.onerror = function() {
console.error('VDL2 stream error');
};
}
function addVdl2Message(data) {
const container = document.getElementById('vdl2Messages');
const placeholder = container.querySelector('.no-aircraft');
if (placeholder) placeholder.remove();
const msg = document.createElement('div');
msg.className = 'vdl2-message-item';
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;';
const station = data.station || '';
const avlc = data.avlc || {};
const src = avlc.src?.addr || '';
const dst = avlc.dst?.addr || '';
const acars = avlc.acars || {};
const flight = acars.flight || '';
const msgText = acars.msg_text || '';
const time = new Date().toLocaleTimeString();
const freq = data.freq ? (data.freq / 1000000).toFixed(3) : '';
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${flight || src || 'VDL2'}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${freq ? `<div style="color: var(--text-dim); font-size: 9px;">${freq} MHz</div>` : ''}
${dst ? `<div style="color: var(--text-muted); font-size: 9px;">To: ${dst}</div>` : ''}
${msgText ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${msgText}</div>` : ''}
`;
container.insertBefore(msg, container.firstChild);
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
// Populate VDL2 device selector
document.addEventListener('DOMContentLoaded', () => {
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('vdl2DeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
if (devices.length > 1) {
select.value = '1';
}
}
});
});
// ============================================
// SQUAWK CODE REFERENCE
// ============================================
@@ -4174,6 +4358,18 @@ sudo make install</code>
document.getElementById('squawkModal')?.addEventListener('click', (e) => {
if (e.target.id === 'squawkModal') closeSquawkModal();
});
// ============================================
// ANTENNA GUIDE
// ============================================
function toggleAntennaGuide() {
const modal = document.getElementById('antennaGuideModal');
modal.classList.toggle('active');
}
document.getElementById('antennaGuideModal')?.addEventListener('click', (e) => {
if (e.target.id === 'antennaGuideModal') toggleAntennaGuide();
});
</script>
<!-- Squawk Code Reference Modal -->
@@ -4227,6 +4423,72 @@ sudo make install</code>
</div>
</div>
<!-- Antenna Guide Modal -->
<div id="antennaGuideModal" class="antenna-guide-modal">
<div class="antenna-guide-modal-content">
<div class="antenna-guide-modal-header">
<span>ANTENNA GUIDE &mdash; 1090 MHz ADS-B</span>
<button class="antenna-guide-modal-close" onclick="toggleAntennaGuide()">&times;</button>
</div>
<div class="antenna-guide-modal-body">
<p style="margin-bottom: 10px; color: var(--accent-cyan, #00d4ff); font-weight: 600; font-size: 12px;">
1090 MHz &mdash; stock SDR antenna can work but is not ideal
</p>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Stock Telescopic Antenna</strong>
<ul>
<li><strong>1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
<li><strong>Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
</ul>
</div>
<div class="antenna-section recommended">
<strong style="color: #00ff88;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
<ul>
<li><strong>Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
<li><strong>Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
<li><strong>Gain:</strong> ~5&ndash;7 dBi omnidirectional, ideal for 360&deg; coverage</li>
<li><strong>Range:</strong> 150&ndash;250+ NM depending on height and LOS</li>
</ul>
</div>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Commercial Options</strong>
<ul>
<li><strong>FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
<li><strong>ADSBexchange whip:</strong> ~$40, similar performance</li>
<li><strong>Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
</ul>
</div>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Placement &amp; LNA</strong>
<ul>
<li><strong>Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
<li><strong>Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
<li><strong>LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
<li><strong>Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
<li><strong>Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
<li><strong>Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
</ul>
</div>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Quick Reference</strong>
<table class="antenna-ref-table">
<tr><td>ADS-B frequency</td><td>1090 MHz</td></tr>
<tr><td>Quarter-wave length</td><td>6.9 cm</td></tr>
<tr><td>Modulation</td><td>PPM (pulse)</td></tr>
<tr><td>Polarization</td><td>Vertical</td></tr>
<tr><td>Bandwidth</td><td>~2 MHz</td></tr>
<tr><td>Typical range (outdoor)</td><td>100&ndash;250 NM</td></tr>
</table>
</div>
</div>
</div>
</div>
<style>
.squawk-clickable {
cursor: pointer;
@@ -4374,6 +4636,108 @@ sudo make install</code>
color: #000;
}
/* Antenna Guide Modal */
.antenna-guide-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.antenna-guide-modal.active {
display: flex;
}
.antenna-guide-modal-content {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.antenna-guide-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00d4ff);
}
.antenna-guide-modal-close {
background: none;
border: none;
color: var(--text-secondary, #888);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
}
.antenna-guide-modal-close:hover {
color: #fff;
}
.antenna-guide-modal-body {
padding: 16px;
overflow-y: auto;
font-size: 11px;
color: var(--text-secondary, #999);
line-height: 1.5;
}
.antenna-section {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
padding: 10px;
margin-bottom: 8px;
}
.antenna-section strong {
font-size: 11px;
display: block;
margin-bottom: 4px;
}
.antenna-section ul {
margin: 4px 0 0 14px;
padding: 0;
font-size: 10px;
}
.antenna-section ul li {
margin-bottom: 2px;
}
.antenna-section ul li strong {
display: inline;
color: var(--text-primary, #fff);
font-size: 10px;
}
.antenna-ref-table {
width: 100%;
margin-top: 6px;
font-size: 10px;
border-collapse: collapse;
}
.antenna-ref-table tr {
border-bottom: 1px solid var(--border-color, #333);
}
.antenna-ref-table td {
padding: 3px 4px;
}
.antenna-ref-table td:first-child {
color: var(--text-dim, #666);
}
.antenna-ref-table td:last-child {
color: var(--text-primary, #fff);
text-align: right;
}
/* Watchlist Modal */
.watchlist-modal {
display: none;