Adding spy stations aka the number stations including diplomatic stations

This commit is contained in:
Marc
2026-01-24 04:19:28 -06:00
parent 446a8f14cb
commit 1b0d39c5b0
7 changed files with 1310 additions and 5 deletions

View File

@@ -0,0 +1,461 @@
/**
* 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}'
};
/**
* 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: 'JetBrains Mono', monospace; 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();
}
/**
* 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` : '';
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">
<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>
<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';
}
/**
* Tune to a station frequency
*/
function tuneToStation(stationId, freqKhz) {
const freqMhz = freqKhz / 1000;
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
sessionStorage.setItem('tuneMode', 'usb'); // Most number stations use USB
// Find the station for notification
const station = stations.find(s => s.id === stationId);
const stationName = station ? station.name : 'Station';
if (typeof showNotification === 'function') {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz));
}
// Switch to listening post mode
if (typeof selectMode === 'function') {
selectMode('listening');
} else if (typeof switchMode === 'function') {
switchMode('listening');
}
}
/**
* Check if we arrived from another page with a tune request
*/
function checkTuneFrequency() {
// This is for the listening post to check - spy stations sets, listening post reads
}
/**
* 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">${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 the Listening Post 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
*/
function updateStats() {
const numberCount = stations.filter(s => s.type === 'number').length;
const diplomaticCount = stations.filter(s => s.type === 'diplomatic').length;
const countryCount = new Set(stations.map(s => s.country_code)).size;
const freqCount = stations.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;
}
// Public API
return {
init,
applyFilters,
tuneToStation,
showDetails,
closeDetails,
showHelp,
closeHelp
};
})();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Will be initialized when mode is switched to spy stations
});