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

@@ -19,6 +19,7 @@ def register_blueprints(app):
from .correlation import correlation_bp
from .listening_post import listening_post_bp
from .tscm import tscm_bp, init_tscm_state
from .spy_stations import spy_stations_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -37,6 +38,7 @@ def register_blueprints(app):
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
# Initialize TSCM state with queue and lock from app
import app as app_module

323
routes/spy_stations.py Normal file
View File

@@ -0,0 +1,323 @@
"""Spy Stations routes - Number stations and diplomatic HF networks."""
from flask import Blueprint, jsonify, request
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
# Active spy stations data from priyom.org
STATIONS = [
# Number Stations (Intelligence)
{
"id": "e06",
"name": "E06",
"nickname": "English Man",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
"operator": "Russian 6",
"schedule": "Weekdays, 2 transmissions 1 hour apart",
"source_url": "https://priyom.org/number-stations/english/e06"
},
{
"id": "s06",
"name": "S06",
"nickname": "Russian Man",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
"operator": "Russian 6",
"schedule": "Same schedule as E06, alternating languages",
"source_url": "https://priyom.org/number-stations/russian/s06"
},
{
"id": "uvb76",
"name": "UVB-76",
"nickname": "The Buzzer",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4625, "primary": True},
{"freq_khz": 5779, "primary": False},
{"freq_khz": 6810, "primary": False},
{"freq_khz": 7490, "primary": False},
],
"mode": "USB",
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
},
{
"id": "hm01",
"name": "HM01",
"nickname": "Cuban Numbers",
"type": "number",
"country": "Cuba",
"country_code": "CU",
"frequencies": [
{"freq_khz": 9065, "primary": True},
{"freq_khz": 9155, "primary": False},
{"freq_khz": 9240, "primary": False},
{"freq_khz": 9330, "primary": False},
{"freq_khz": 10345, "primary": False},
{"freq_khz": 10715, "primary": False},
{"freq_khz": 10860, "primary": False},
{"freq_khz": 11435, "primary": False},
{"freq_khz": 11462, "primary": False},
{"freq_khz": 11530, "primary": False},
{"freq_khz": 11635, "primary": False},
{"freq_khz": 12180, "primary": False},
{"freq_khz": 13435, "primary": False},
{"freq_khz": 14375, "primary": False},
{"freq_khz": 16180, "primary": False},
{"freq_khz": 17480, "primary": False},
],
"mode": "AM/OFDM",
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
"operator": "DGI (Cuban Intelligence)",
"schedule": "Multiple daily transmissions",
"source_url": "https://priyom.org/number-stations/cuba/hm01"
},
# Diplomatic Stations
{
"id": "bulgaria_mfa",
"name": "Bulgaria MFA",
"nickname": "Sofia Diplomatic",
"type": "diplomatic",
"country": "Bulgaria",
"country_code": "BG",
"frequencies": [
{"freq_khz": 5145, "primary": True},
{"freq_khz": 6755, "primary": False},
{"freq_khz": 7670, "primary": False},
{"freq_khz": 9155, "primary": False},
{"freq_khz": 10175, "primary": False},
{"freq_khz": 11445, "primary": False},
{"freq_khz": 14725, "primary": False},
{"freq_khz": 18520, "primary": False},
],
"mode": "RFSM-8000/MIL-STD-188-110",
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
"operator": "Bulgarian MFA",
"schedule": "Daily scheduled transmissions",
"source_url": "https://priyom.org/diplomatic/bulgaria"
},
{
"id": "czechia_mfa",
"name": "Czechia MFA",
"nickname": "Czech Diplomatic",
"type": "diplomatic",
"country": "Czechia",
"country_code": "CZ",
"frequencies": [
{"freq_khz": 6830, "primary": True},
{"freq_khz": 8130, "primary": False},
{"freq_khz": 10232, "primary": False},
{"freq_khz": 13890, "primary": False},
],
"mode": "PACTOR-III",
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
"operator": "Czech MFA / MoD",
"schedule": "Regular scheduled traffic",
"source_url": "https://priyom.org/diplomatic/czechia"
},
{
"id": "egypt_mfa",
"name": "Egypt MFA",
"nickname": "Egyptian Diplomatic",
"type": "diplomatic",
"country": "Egypt",
"country_code": "EG",
"frequencies": [
{"freq_khz": 7830, "primary": True},
{"freq_khz": 9048, "primary": False},
{"freq_khz": 10780, "primary": False},
{"freq_khz": 13950, "primary": False},
],
"mode": "SITOR/Codan 3012",
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
"operator": "Egyptian MFA",
"schedule": "Daily traffic windows",
"source_url": "https://priyom.org/diplomatic/egypt"
},
{
"id": "dprk_mfa",
"name": "DPRK MFA",
"nickname": "North Korea Diplomatic",
"type": "diplomatic",
"country": "North Korea",
"country_code": "KP",
"frequencies": [
{"freq_khz": 7200, "primary": True},
{"freq_khz": 9450, "primary": False},
{"freq_khz": 11475, "primary": False},
{"freq_khz": 13785, "primary": False},
{"freq_khz": 15245, "primary": False},
{"freq_khz": 17550, "primary": False},
{"freq_khz": 21680, "primary": False},
{"freq_khz": 25120, "primary": False},
],
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
"operator": "DPRK MFA",
"schedule": "Daily, multiple time slots",
"source_url": "https://priyom.org/diplomatic/north-korea"
},
{
"id": "russia_mfa",
"name": "Russia MFA",
"nickname": "Russian Diplomatic",
"type": "diplomatic",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 5154, "primary": True},
{"freq_khz": 7654, "primary": False},
{"freq_khz": 9045, "primary": False},
{"freq_khz": 10755, "primary": False},
{"freq_khz": 13455, "primary": False},
{"freq_khz": 16354, "primary": False},
{"freq_khz": 18954, "primary": False},
],
"mode": "Perelivt/Serdolik/X06/OFDM",
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
"operator": "Russian MFA",
"schedule": "24/7 network operations",
"source_url": "https://priyom.org/diplomatic/russia"
},
{
"id": "tunisia_mfa",
"name": "Tunisia MFA",
"nickname": "Tunisian Diplomatic",
"type": "diplomatic",
"country": "Tunisia",
"country_code": "TN",
"frequencies": [
{"freq_khz": 5810, "primary": True},
{"freq_khz": 7954, "primary": False},
{"freq_khz": 8014, "primary": False},
{"freq_khz": 8180, "primary": False},
{"freq_khz": 10113, "primary": False},
{"freq_khz": 10176, "primary": False},
{"freq_khz": 11111, "primary": False},
{"freq_khz": 12140, "primary": False},
{"freq_khz": 13945, "primary": False},
{"freq_khz": 14700, "primary": False},
{"freq_khz": 14724, "primary": False},
{"freq_khz": 15635, "primary": False},
{"freq_khz": 16125, "primary": False},
{"freq_khz": 16285, "primary": False},
{"freq_khz": 16290, "primary": False},
{"freq_khz": 18295, "primary": False},
{"freq_khz": 19675, "primary": False},
{"freq_khz": 23540, "primary": False},
{"freq_khz": 24080, "primary": False},
{"freq_khz": 24170, "primary": False},
{"freq_khz": 26890, "primary": False},
],
"mode": "2G ALE/PACTOR-II",
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
"operator": "Tunisian MFA",
"schedule": "Regular diplomatic traffic",
"source_url": "https://priyom.org/diplomatic/tunisia"
},
{
"id": "usa_state",
"name": "US State Dept",
"nickname": "American Diplomatic",
"type": "diplomatic",
"country": "United States",
"country_code": "US",
"frequencies": [
{"freq_khz": 5749, "primary": True},
{"freq_khz": 6903, "primary": False},
{"freq_khz": 8059, "primary": False},
{"freq_khz": 10734, "primary": False},
{"freq_khz": 11169, "primary": False},
{"freq_khz": 13504, "primary": False},
{"freq_khz": 16284, "primary": False},
{"freq_khz": 18249, "primary": False},
{"freq_khz": 20811, "primary": False},
{"freq_khz": 24884, "primary": False},
],
"mode": "2G ALE (MIL-STD-188-141A)",
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
"operator": "US State Department",
"schedule": "24/7 global network",
"source_url": "https://priyom.org/diplomatic/united-states"
},
]
@spy_stations_bp.route('/stations')
def get_stations():
"""Return all spy stations, optionally filtered."""
station_type = request.args.get('type')
country = request.args.get('country')
mode = request.args.get('mode')
filtered = STATIONS
if station_type:
filtered = [s for s in filtered if s['type'] == station_type]
if country:
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
if mode:
mode_lower = mode.lower()
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
return jsonify({
'status': 'success',
'count': len(filtered),
'stations': filtered
})
@spy_stations_bp.route('/stations/<station_id>')
def get_station(station_id):
"""Get a single station by ID."""
for station in STATIONS:
if station['id'] == station_id:
return jsonify({
'status': 'success',
'station': station
})
return jsonify({
'status': 'error',
'message': 'Station not found'
}), 404
@spy_stations_bp.route('/filters')
def get_filters():
"""Return available filter options."""
types = list(set(s['type'] for s in STATIONS))
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
return jsonify({
'status': 'success',
'filters': {
'types': types,
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
'modes': modes
}
})

View File

@@ -0,0 +1,355 @@
/**
* Spy Stations Mode Styles
* Number stations and diplomatic HF networks
*/
/* ============================================
MAIN LAYOUT
============================================ */
.spy-stations-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow: hidden;
}
.spy-stations-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.spy-stations-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.spy-stations-title svg {
color: var(--accent-cyan);
}
.spy-stations-count {
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 4px 10px;
border-radius: 12px;
}
/* ============================================
STATION GRID
============================================ */
.spy-stations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
overflow-y: auto;
flex: 1;
padding: 4px;
}
/* ============================================
STATION CARD
============================================ */
.spy-station-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
}
.spy-station-card:hover {
border-color: var(--border-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Card Header */
.spy-station-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.spy-station-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.spy-station-flag {
font-size: 18px;
line-height: 1;
}
.spy-station-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.spy-station-nickname {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Type Badge */
.spy-station-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
flex-shrink: 0;
}
.spy-badge-number {
background: rgba(74, 158, 255, 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(74, 158, 255, 0.3);
}
.spy-badge-diplomatic {
background: rgba(34, 197, 94, 0.15);
color: var(--accent-green);
border: 1px solid rgba(34, 197, 94, 0.3);
}
/* Card Body */
.spy-station-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.spy-station-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.spy-station-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.spy-meta-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.spy-meta-value {
font-size: 12px;
color: var(--text-primary);
}
.spy-meta-mode {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-orange);
}
/* Frequencies */
.spy-station-freqs {
display: flex;
flex-direction: column;
gap: 4px;
}
.spy-freq-list {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
line-height: 1.6;
}
.spy-freq-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.spy-freq-item {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
/* Description */
.spy-station-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Card Footer */
.spy-station-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.1);
border-top: 1px solid var(--border-color);
}
/* Tune Button */
.spy-tune-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #000;
background: var(--accent-green);
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.spy-tune-btn:hover {
background: var(--accent-cyan);
transform: scale(1.02);
}
.spy-tune-btn svg {
stroke-width: 2.5;
}
/* Details Button */
.spy-details-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.spy-details-btn:hover {
color: var(--text-primary);
border-color: var(--border-light);
background: var(--bg-secondary);
}
/* ============================================
EMPTY STATE
============================================ */
.spy-station-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
}
.spy-station-empty p {
font-size: 13px;
margin-top: 8px;
}
/* ============================================
FILTER CHECKBOX STYLING
============================================ */
#spystationsMode .inline-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
#spystationsMode .inline-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent-cyan);
}
#spystationsMode .inline-checkbox:hover {
color: var(--text-primary);
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 768px) {
.spy-stations-grid {
grid-template-columns: 1fr;
}
.spy-station-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.spy-station-badge {
align-self: flex-start;
}
.spy-station-meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.spy-station-footer {
flex-direction: column;
gap: 8px;
}
.spy-tune-btn,
.spy-details-btn {
width: 100%;
justify-content: center;
}
}

View File

@@ -1580,6 +1580,40 @@ function initListeningPost() {
e.preventDefault();
tuneFreq(delta);
});
// Check if we arrived from Spy Stations with a tune request
checkIncomingTuneRequest();
}
/**
* Check for incoming tune request from Spy Stations or other pages
*/
function checkIncomingTuneRequest() {
const tuneFreq = sessionStorage.getItem('tuneFrequency');
const tuneMode = sessionStorage.getItem('tuneMode');
if (tuneFreq) {
// Clear the session storage first
sessionStorage.removeItem('tuneFrequency');
sessionStorage.removeItem('tuneMode');
// Parse and validate frequency
const freq = parseFloat(tuneFreq);
if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) {
console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default');
// Determine modulation (default to USB for HF/number stations)
const mod = tuneMode || (freq < 30 ? 'usb' : 'am');
// Use quickTune to set frequency and modulation
quickTune(freq, mod);
// Show notification
if (typeof showNotification === 'function') {
showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode');
}
}
}
}
// Initialize when DOM is ready
@@ -2265,6 +2299,7 @@ window.skipSignal = skipSignal;
window.setBand = setBand;
window.tuneFreq = tuneFreq;
window.quickTune = quickTune;
window.checkIncomingTuneRequest = checkIncomingTuneRequest;
window.addFrequencyBookmark = addFrequencyBookmark;
window.removeBookmark = removeBookmark;
window.tuneToFrequency = tuneToFrequency;

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
});

View File

@@ -21,6 +21,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
</head>
<body>
@@ -135,6 +136,11 @@
<span class="mode-name">RTLAMR</span>
<span class="mode-desc">Utility meters</span>
</button>
<button class="mode-card" onclick="selectMode('spystations')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span>
<span class="mode-name">Spy Stations</span>
<span class="mode-desc">Number stations & diplomatic</span>
</button>
</div>
</div>
</div>
@@ -280,6 +286,7 @@
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span><span class="nav-label">Spy Stations</span></button>
</div>
</div>
<div class="mode-nav-dropdown" data-group="wireless">
@@ -343,6 +350,7 @@
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
</nav>
<!-- Mobile Drawer Overlay -->
@@ -455,6 +463,8 @@
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %}
<button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
@@ -1447,6 +1457,28 @@
</div>
</div>
<!-- Spy Stations Dashboard -->
<div id="spyStationsVisuals" class="spy-stations-container" style="display: none;">
<div class="spy-stations-header">
<div class="spy-stations-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 20px; height: 20px;">
<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>
Number Stations & Diplomatic Networks
</div>
<div class="spy-stations-count">
<span id="spyStationsVisibleCount">0</span> stations
</div>
</div>
<div class="spy-stations-grid" id="spyStationsGrid">
<!-- Station cards populated by JavaScript -->
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -1528,6 +1560,7 @@
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
<script>
// ============================================
@@ -1996,7 +2029,8 @@
'pager': 'sdr', 'sensor': 'sdr',
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
'wifi': 'wireless', 'bluetooth': 'wireless',
'tscm': 'security'
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr'
};
// Remove has-active from all dropdowns
@@ -2056,6 +2090,7 @@
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
@@ -2086,7 +2121,8 @@
'listening': 'LISTENING POST',
'aprs': 'APRS',
'tscm': 'TSCM',
'ais': 'AIS VESSELS'
'ais': 'AIS VESSELS',
'spystations': 'SPY STATIONS'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -2096,12 +2132,14 @@
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
const aprsVisuals = document.getElementById('aprsVisuals');
const tscmVisuals = document.getElementById('tscmVisuals');
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
// Show/hide mode-specific timeline containers
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
@@ -2120,7 +2158,8 @@
'listening': 'Listening Post',
'aprs': 'APRS Tracker',
'tscm': 'TSCM Counter-Surveillance',
'ais': 'AIS Vessel Tracker'
'ais': 'AIS Vessel Tracker',
'spystations': 'Spy Stations'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -2138,7 +2177,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm') {
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -2164,7 +2203,7 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm') ? 'none' : 'block';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
@@ -2189,6 +2228,13 @@
} else if (mode === 'satellite') {
initPolarPlot();
initSatelliteList();
} else if (mode === 'listening') {
// Check for incoming tune requests from Spy Stations
if (typeof checkIncomingTuneRequest === 'function') {
checkIncomingTuneRequest();
}
} else if (mode === 'spystations') {
SpyStations.init();
}
}

View File

@@ -0,0 +1,83 @@
<!-- SPY STATIONS MODE -->
<div id="spystationsMode" class="mode-content" style="display: none;">
<div class="section">
<h3>Spy Stations</h3>
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
Active number stations and diplomatic HF radio networks. Data sourced from priyom.org.
</p>
</div>
<div class="section">
<h3>Filter by Type</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<label class="inline-checkbox">
<input type="checkbox" id="filterTypeNumber" checked onchange="SpyStations.applyFilters()">
<span style="color: var(--accent-cyan);">Number Stations</span>
</label>
<label class="inline-checkbox">
<input type="checkbox" id="filterTypeDiplomatic" checked onchange="SpyStations.applyFilters()">
<span style="color: var(--accent-green);">Diplomatic Networks</span>
</label>
</div>
</div>
<div class="section">
<h3>Filter by Country</h3>
<div id="countryFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto;">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="section">
<h3>Filter by Mode</h3>
<div id="modeFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow-y: auto;">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="section">
<h3>Quick Stats</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);" id="spyStatsNumber">0</div>
<div style="font-size: 9px; color: var(--text-muted);">NUMBER</div>
</div>
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-green);" id="spyStatsDiplomatic">0</div>
<div style="font-size: 9px; color: var(--text-muted);">DIPLOMATIC</div>
</div>
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-orange);" id="spyStatsCountries">0</div>
<div style="font-size: 9px; color: var(--text-muted);">COUNTRIES</div>
</div>
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-purple);" id="spyStatsFreqs">0</div>
<div style="font-size: 9px; color: var(--text-muted);">FREQUENCIES</div>
</div>
</div>
</div>
<div class="section">
<h3>Help</h3>
<button class="preset-btn" onclick="SpyStations.showHelp()" style="width: 100%;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
About Spy Stations
</button>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://priyom.org" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Priyom.org
</a>
<a href="https://www.numbers-stations.com" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Numbers-Stations.com
</a>
</div>
</div>
</div>