Files
intercept/static/js/modes/spy-stations.js

535 lines
23 KiB
JavaScript

/**
* Spy Stations Mode
* Number stations and diplomatic HF radio networks
*/
const SpyStations = (function() {
// State
let stations = [];
let filteredStations = [];
let activeFilters = {
types: ['number', 'diplomatic'],
countries: [],
modes: []
};
// Country flag emoji map
const countryFlags = {
'RU': '\u{1F1F7}\u{1F1FA}',
'CU': '\u{1F1E8}\u{1F1FA}',
'BG': '\u{1F1E7}\u{1F1EC}',
'CZ': '\u{1F1E8}\u{1F1FF}',
'EG': '\u{1F1EA}\u{1F1EC}',
'KP': '\u{1F1F0}\u{1F1F5}',
'TN': '\u{1F1F9}\u{1F1F3}',
'US': '\u{1F1FA}\u{1F1F8}',
'PL': '\u{1F1F5}\u{1F1F1}',
'IL': '\u{1F1EE}\u{1F1F1}',
'CN': '\u{1F1E8}\u{1F1F3}',
'MA': '\u{1F1F2}\u{1F1E6}',
'FR': '\u{1F1EB}\u{1F1F7}',
'RO': '\u{1F1F7}\u{1F1F4}',
'DZ': '\u{1F1E9}\u{1F1FF}'
};
/**
* Initialize the spy stations mode
*/
function init() {
fetchStations();
checkTuneFrequency();
}
/**
* Fetch stations from the API
*/
async function fetchStations() {
try {
const response = await fetch('/spy-stations/stations');
const data = await response.json();
if (data.status === 'success') {
stations = data.stations;
initFilters();
applyFilters();
updateStats();
}
} catch (err) {
console.error('Failed to fetch spy stations:', err);
}
}
/**
* Initialize filter checkboxes
*/
function initFilters() {
// Get unique countries and modes
const countries = [...new Set(stations.map(s => JSON.stringify({name: s.country, code: s.country_code})))].map(s => JSON.parse(s));
const modes = [...new Set(stations.map(s => s.mode.split('/')[0]))].sort();
// Populate country filters
const countryContainer = document.getElementById('countryFilters');
if (countryContainer) {
countryContainer.innerHTML = countries.map(c => `
<label class="inline-checkbox">
<input type="checkbox" data-country="${c.code}" checked onchange="SpyStations.applyFilters()">
<span>${countryFlags[c.code] || ''} ${c.name}</span>
</label>
`).join('');
}
// Populate mode filters
const modeContainer = document.getElementById('modeFilters');
if (modeContainer) {
modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox">
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 10px;">${m}</span>
</label>
`).join('');
}
// Set initial filter states
activeFilters.countries = countries.map(c => c.code);
activeFilters.modes = modes;
}
/**
* Apply filters and render stations
*/
function applyFilters() {
// Read type filters
const typeNumber = document.getElementById('filterTypeNumber');
const typeDiplomatic = document.getElementById('filterTypeDiplomatic');
activeFilters.types = [];
if (typeNumber && typeNumber.checked) activeFilters.types.push('number');
if (typeDiplomatic && typeDiplomatic.checked) activeFilters.types.push('diplomatic');
// Read country filters
activeFilters.countries = [];
document.querySelectorAll('#countryFilters input[data-country]:checked').forEach(cb => {
activeFilters.countries.push(cb.dataset.country);
});
// Read mode filters
activeFilters.modes = [];
document.querySelectorAll('#modeFilters input[data-mode]:checked').forEach(cb => {
activeFilters.modes.push(cb.dataset.mode);
});
// Apply filters
filteredStations = stations.filter(s => {
if (!activeFilters.types.includes(s.type)) return false;
if (!activeFilters.countries.includes(s.country_code)) return false;
const stationMode = s.mode.split('/')[0];
if (!activeFilters.modes.includes(stationMode)) return false;
return true;
});
renderStations();
updateStats(true);
}
/**
* Render station cards
*/
function renderStations() {
const container = document.getElementById('spyStationsGrid');
if (!container) return;
if (filteredStations.length === 0) {
container.innerHTML = `
<div class="spy-station-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 12px;">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
<circle cx="12" cy="12" r="2"/>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
</svg>
<p>No stations match your filters</p>
</div>
`;
return;
}
container.innerHTML = filteredStations.map(station => renderStationCard(station)).join('');
}
/**
* Render a single station card
*/
function renderStationCard(station) {
const flag = countryFlags[station.country_code] || '';
const typeBadgeClass = station.type === 'number' ? 'spy-badge-number' : 'spy-badge-diplomatic';
const typeBadgeText = station.type === 'number' ? 'NUMBER' : 'DIPLOMATIC';
const primaryFreq = station.frequencies.find(f => f.primary) || station.frequencies[0];
const freqList = station.frequencies.slice(0, 4).map(f => formatFrequency(f.freq_khz)).join(', ');
const moreFreqs = station.frequencies.length > 4 ? ` +${station.frequencies.length - 4} more` : '';
// Build tune button with frequency selector if multiple frequencies
let tuneSection;
if (station.frequencies.length > 1) {
const options = station.frequencies.map(f => {
const label = formatFrequency(f.freq_khz) + (f.primary ? ' (primary)' : '');
return `<option value="${f.freq_khz}">${label}</option>`;
}).join('');
tuneSection = `
<div class="spy-tune-group">
<select class="spy-freq-select" id="freq-select-${station.id}">
${options}
</select>
<button class="spy-tune-btn" onclick="SpyStations.tuneToSelectedFreq('${station.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
Tune In
</button>
</div>
`;
} else {
tuneSection = `
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${primaryFreq.freq_khz})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
Tune In
</button>
`;
}
return `
<div class="spy-station-card" data-station-id="${station.id}">
<div class="spy-station-header">
<div class="spy-station-title">
<span class="spy-station-flag">${flag}</span>
<span class="spy-station-name">${station.name}</span>
${station.nickname ? `<span class="spy-station-nickname">- ${station.nickname}</span>` : ''}
</div>
<span class="spy-station-badge ${typeBadgeClass}">${typeBadgeText}</span>
</div>
<div class="spy-station-body">
<div class="spy-station-meta">
<div class="spy-station-meta-item">
<span class="spy-meta-label">Origin</span>
<span class="spy-meta-value">${station.country}</span>
</div>
<div class="spy-station-meta-item">
<span class="spy-meta-label">Mode</span>
<span class="spy-meta-value spy-meta-mode">${station.mode}</span>
</div>
</div>
<div class="spy-station-freqs">
<span class="spy-meta-label">Frequencies</span>
<span class="spy-freq-list">${freqList}${moreFreqs}</span>
</div>
<div class="spy-station-desc">${station.description}</div>
</div>
<div class="spy-station-footer">
${tuneSection}
<button class="spy-details-btn" onclick="SpyStations.showDetails('${station.id}')">
Details
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
`;
}
/**
* Format frequency for display
*/
function formatFrequency(freqKhz) {
if (freqKhz >= 1000) {
return (freqKhz / 1000).toFixed(3) + ' MHz';
}
return freqKhz + ' kHz';
}
/**
* Get appropriate SDR mode from station mode string
*/
function getModeFromStation(stationMode) {
const mode = stationMode.toLowerCase();
if (mode.includes('am') || mode.includes('ofdm')) return 'am';
if (mode.includes('lsb')) return 'lsb';
if (mode.includes('fm')) return 'fm';
// Default to USB for most number stations and digital modes
return 'usb';
}
/**
* Tune to a station frequency
*/
function tuneToStation(stationId, freqKhz) {
const freqMhz = freqKhz / 1000;
// Find the station and determine mode
const station = stations.find(s => s.id === stationId);
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
const stationName = station ? station.name : 'Station';
if (typeof showNotification === 'function') {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
}
// Switch to spectrum waterfall mode and tune after mode init.
if (typeof switchMode === 'function') {
switchMode('waterfall');
} else if (typeof selectMode === 'function') {
selectMode('waterfall');
}
setTimeout(() => {
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
Waterfall.quickTune(freqMhz, tuneMode);
}
}, 220);
}
/**
* Tune to selected frequency from dropdown
*/
function tuneToSelectedFreq(stationId) {
const select = document.getElementById('freq-select-' + stationId);
if (select) {
const freqKhz = parseInt(select.value, 10);
tuneToStation(stationId, freqKhz);
}
}
/**
* Check if we arrived from another page with a tune request
*/
function checkTuneFrequency() {
// Reserved for cross-mode tune handoff behavior.
}
/**
* Show station details modal
*/
function showDetails(stationId) {
const station = stations.find(s => s.id === stationId);
if (!station) return;
let modal = document.getElementById('spyStationDetailsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'spyStationDetailsModal';
modal.className = 'signal-details-modal';
document.body.appendChild(modal);
}
const flag = countryFlags[station.country_code] || '';
const allFreqs = station.frequencies.map(f => {
const label = f.primary ? ' (primary)' : '';
return `<span class="spy-freq-item spy-freq-clickable" onclick="SpyStations.tuneToStation('${station.id}', ${f.freq_khz}); SpyStations.closeDetails();">${formatFrequency(f.freq_khz)}${label}</span>`;
}).join('');
modal.innerHTML = `
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeDetails()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>${flag} ${station.name} ${station.nickname ? '- ' + station.nickname : ''}</h3>
<button class="signal-details-modal-close" onclick="SpyStations.closeDetails()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div class="signal-details-section">
<div class="signal-details-title">Overview</div>
<div class="signal-details-grid">
<div class="signal-details-item">
<span class="signal-details-label">Type</span>
<span class="signal-details-value">${station.type === 'number' ? 'Number Station' : 'Diplomatic Network'}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Country</span>
<span class="signal-details-value">${station.country}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Mode</span>
<span class="signal-details-value">${station.mode}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Operator</span>
<span class="signal-details-value">${station.operator || 'Unknown'}</span>
</div>
</div>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Description</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">${station.description}</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Frequencies (${station.frequencies.length})</div>
<div class="spy-freq-grid">${allFreqs}</div>
</div>
${station.schedule ? `
<div class="signal-details-section">
<div class="signal-details-title">Schedule</div>
<p style="color: var(--text-secondary); font-size: 12px;">${station.schedule}</p>
</div>
` : ''}
${station.source_url ? `
<div class="signal-details-section">
<div class="signal-details-title">Source</div>
<a href="${station.source_url}" target="_blank" rel="noopener" style="color: var(--accent-cyan); font-size: 12px;">${station.source_url}</a>
</div>
` : ''}
</div>
<div class="signal-details-modal-footer">
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${station.frequencies[0].freq_khz}); SpyStations.closeDetails();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
Tune In
</button>
</div>
</div>
`;
modal.classList.add('show');
}
/**
* Close details modal
*/
function closeDetails() {
const modal = document.getElementById('spyStationDetailsModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* Show help modal
*/
function showHelp() {
let modal = document.getElementById('spyStationsHelpModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'spyStationsHelpModal';
modal.className = 'signal-details-modal';
document.body.appendChild(modal);
}
modal.innerHTML = `
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeHelp()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>About Spy Stations</h3>
<button class="signal-details-modal-close" onclick="SpyStations.closeHelp()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div class="signal-details-section">
<div class="signal-details-title">Number Stations</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Number stations are shortwave radio transmissions believed to be used by intelligence agencies
to communicate with spies in the field. They typically broadcast strings of numbers, letters,
or words read by synthesized or live voices. These one-way broadcasts are encrypted using
one-time pads, making them virtually unbreakable.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Diplomatic Networks</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Foreign ministries maintain HF radio networks to communicate with embassies worldwide,
especially in regions where satellite or internet connectivity may be unreliable or
compromised. These networks use various digital modes like PACTOR, ALE, and proprietary
protocols for encrypted diplomatic traffic.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">How to Listen</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured.
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Best Practices</div>
<ul style="color: var(--text-secondary); font-size: 12px; line-height: 1.6; padding-left: 20px;">
<li>HF propagation varies with time of day and solar conditions</li>
<li>Use a long wire or loop antenna for best results</li>
<li>Check schedules on priyom.org for transmission times</li>
<li>Night time generally offers better long-distance reception</li>
</ul>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Data Sources</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Station data sourced from <a href="https://priyom.org" target="_blank" rel="noopener" style="color: var(--accent-cyan);">priyom.org</a>,
a community-maintained database of number stations and related transmissions.
</p>
</div>
</div>
</div>
`;
modal.classList.add('show');
}
/**
* Close help modal
*/
function closeHelp() {
const modal = document.getElementById('spyStationsHelpModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* Update sidebar stats
* @param {boolean} useFiltered - If true, use filtered stations instead of all stations
*/
function updateStats(useFiltered) {
const stationList = useFiltered ? filteredStations : stations;
const numberCount = stationList.filter(s => s.type === 'number').length;
const diplomaticCount = stationList.filter(s => s.type === 'diplomatic').length;
const countryCount = new Set(stationList.map(s => s.country_code)).size;
const freqCount = stationList.reduce((sum, s) => sum + s.frequencies.length, 0);
const numberEl = document.getElementById('spyStatsNumber');
const diplomaticEl = document.getElementById('spyStatsDiplomatic');
const countriesEl = document.getElementById('spyStatsCountries');
const freqsEl = document.getElementById('spyStatsFreqs');
if (numberEl) numberEl.textContent = numberCount;
if (diplomaticEl) diplomaticEl.textContent = diplomaticCount;
if (countriesEl) countriesEl.textContent = countryCount;
if (freqsEl) freqsEl.textContent = freqCount;
// Update visible count in header if element exists
const visibleCountEl = document.getElementById('spyStationsVisibleCount');
if (visibleCountEl) {
visibleCountEl.textContent = stationList.length;
}
}
// Public API
return {
init,
applyFilters,
tuneToStation,
tuneToSelectedFreq,
showDetails,
closeDetails,
showHelp,
closeHelp
};
})();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Will be initialized when mode is switched to spy stations
});