mirror of
https://github.com/smittix/intercept.git
synced 2026-07-30 19:38:12 -07:00
16552 lines
854 KiB
HTML
16552 lines
854 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>iNTERCEPT // See the Invisible</title>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<link rel="manifest" href="/static/manifest.json">
|
||
<meta name="theme-color" content="#0b1118">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||
<!-- Preconnect hints for CDN domains -->
|
||
{% if offline_settings.assets_source != 'local' %}
|
||
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||
{% endif %}
|
||
{% if offline_settings.fonts_source != 'local' %}
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
{% endif %}
|
||
<!-- Disclaimer gate - must accept before seeing welcome page -->
|
||
<script>
|
||
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
||
if (localStorage.getItem('disclaimerAccepted') !== 'true') {
|
||
document.write('<style id="disclaimer-gate">.welcome-overlay{display:none !important}</style>');
|
||
window._showDisclaimerOnLoad = true;
|
||
}
|
||
// If navigating with a mode param (e.g. /?mode=waterfall), hide welcome immediately
|
||
// to prevent flash of welcome screen before JS applies the mode
|
||
else if (new URLSearchParams(window.location.search).get('mode')) {
|
||
document.write('<style id="mode-gate">.welcome-overlay{display:none !important}</style>');
|
||
}
|
||
</script>
|
||
<script>
|
||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||
</script>
|
||
<!-- Fonts - Conditional CDN/Local loading -->
|
||
{% if offline_settings.fonts_source == 'local' %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||
{% else %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||
{% endif %}
|
||
<!-- Leaflet CSS -->
|
||
{% if offline_settings.assets_source == 'local' %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||
{% else %}
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||
{% endif %}
|
||
<!-- Core CSS -->
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||
<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/settings.css') }}?v={{ version }}&r=maptheme17">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-waveform.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
|
||
<!-- Deferred scripts - Leaflet, Chart.js, observer-location -->
|
||
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||
{% if offline_settings.assets_source == 'local' %}
|
||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||
<script defer src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.js') }}"></script>
|
||
{% else %}
|
||
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||
<script defer src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||
{% endif %}
|
||
{% if offline_settings.assets_source == 'local' %}
|
||
<script defer src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
|
||
{% else %}
|
||
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||
{% endif %}
|
||
<script defer src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
|
||
<script>
|
||
window.INTERCEPT_MODE_STYLE_MAP = {
|
||
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
|
||
tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}",
|
||
spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
|
||
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
|
||
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
|
||
weathersat: "{{ url_for('static', filename='css/modes/weather-satellite.css') }}",
|
||
sstv_general: "{{ url_for('static', filename='css/modes/sstv-general.css') }}",
|
||
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
|
||
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
|
||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||
wifi_locate: "{{ url_for('static', filename='css/modes/wifi_locate.css') }}?v={{ version }}&r=wflocate1",
|
||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||
morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
|
||
radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}",
|
||
meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}",
|
||
system: "{{ url_for('static', filename='css/modes/system.css') }}",
|
||
ook: "{{ url_for('static', filename='css/modes/ook.css') }}"
|
||
};
|
||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
||
window.ensureModeStyles = function(mode) {
|
||
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
|
||
if (!href) return Promise.resolve();
|
||
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loaded') {
|
||
return Promise.resolve();
|
||
}
|
||
if (window.INTERCEPT_MODE_STYLE_PROMISES[href]) {
|
||
return window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||
}
|
||
const absHref = new URL(href, window.location.href).href;
|
||
const existing = Array.from(document.querySelectorAll('link[data-mode-style]'))
|
||
.find((link) => link.href === absHref);
|
||
if (existing && existing.sheet) {
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||
return Promise.resolve();
|
||
}
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
|
||
const link = existing || document.createElement('link');
|
||
if (!existing) {
|
||
link.rel = 'stylesheet';
|
||
link.href = href;
|
||
link.dataset.modeStyle = mode;
|
||
}
|
||
const promise = new Promise((resolve, reject) => {
|
||
const onLoad = () => {
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||
resolve();
|
||
};
|
||
const onError = () => {
|
||
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
|
||
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
|
||
try {
|
||
link.remove();
|
||
} catch (_) {}
|
||
reject(new Error(`failed to load mode stylesheet: ${mode}`));
|
||
};
|
||
link.addEventListener('load', onLoad, { once: true });
|
||
link.addEventListener('error', onError, { once: true });
|
||
if (existing) {
|
||
// Existing links may have finished loading before listeners attached.
|
||
if (existing.sheet) onLoad();
|
||
} else {
|
||
document.head.appendChild(link);
|
||
}
|
||
});
|
||
window.INTERCEPT_MODE_STYLE_PROMISES[href] = promise;
|
||
return promise;
|
||
};
|
||
// Start loading a deep-linked mode stylesheet as early as possible.
|
||
(function preloadQueryModeStyles() {
|
||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
||
const mode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||
if (!mode) return;
|
||
window.ensureModeStyles(mode).catch(() => {});
|
||
})();
|
||
// Do not warm every mode stylesheet on the welcome page. The eager
|
||
// background fetch storm was adding substantial cross-mode load and
|
||
// delaying dedicated dashboards like ADS-B.
|
||
</script>
|
||
<script>
|
||
window.INTERCEPT_MODE_SCRIPT_MAP = {
|
||
bluetooth: "{{ url_for('static', filename='js/modes/bluetooth.js') }}?v={{ version }}&r=btlocate2",
|
||
wifi: "{{ url_for('static', filename='js/modes/wifi.js') }}",
|
||
spystations: "{{ url_for('static', filename='js/modes/spy-stations.js') }}",
|
||
meshtastic: "{{ url_for('static', filename='js/modes/meshtastic.js') }}",
|
||
sstv: "{{ url_for('static', filename='js/modes/sstv.js') }}",
|
||
weathersat: "{{ url_for('static', filename='js/modes/weather-satellite.js') }}",
|
||
sstv_general: "{{ url_for('static', filename='js/modes/sstv-general.js') }}",
|
||
gps: "{{ url_for('static', filename='js/modes/gps.js') }}",
|
||
websdr: "{{ url_for('static', filename='js/modes/websdr.js') }}",
|
||
subghz: "{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9",
|
||
bt_locate: "{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4",
|
||
wifi_locate: "{{ url_for('static', filename='js/modes/wifi_locate.js') }}?v={{ version }}&r=wflocate1",
|
||
wefax: "{{ url_for('static', filename='js/modes/wefax.js') }}",
|
||
morse: "{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12",
|
||
ook: "{{ url_for('static', filename='js/modes/ook.js') }}?v={{ version }}&r=ook2",
|
||
spaceweather: "{{ url_for('static', filename='js/modes/space-weather.js') }}",
|
||
system: "{{ url_for('static', filename='js/modes/system.js') }}",
|
||
meteor: "{{ url_for('static', filename='js/modes/meteor.js') }}",
|
||
waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"
|
||
};
|
||
window.INTERCEPT_MODE_SCRIPT_LOADED = {};
|
||
window.INTERCEPT_MODE_SCRIPT_PROMISES = {};
|
||
window.ensureModeScript = function(mode) {
|
||
var src = window.INTERCEPT_MODE_SCRIPT_MAP ? window.INTERCEPT_MODE_SCRIPT_MAP[mode] : null;
|
||
if (!src) return Promise.resolve();
|
||
if (window.INTERCEPT_MODE_SCRIPT_LOADED[src]) return Promise.resolve();
|
||
if (window.INTERCEPT_MODE_SCRIPT_PROMISES[src]) return window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
|
||
var promise = new Promise(function(resolve, reject) {
|
||
var script = document.createElement('script');
|
||
script.src = src;
|
||
script.dataset.modeScript = mode;
|
||
script.onload = function() {
|
||
window.INTERCEPT_MODE_SCRIPT_LOADED[src] = true;
|
||
delete window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
|
||
resolve();
|
||
};
|
||
script.onerror = function() {
|
||
delete window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
|
||
reject(new Error('failed to load mode script: ' + mode));
|
||
};
|
||
(document.body || document.head).appendChild(script);
|
||
});
|
||
window.INTERCEPT_MODE_SCRIPT_PROMISES[src] = promise;
|
||
return promise;
|
||
};
|
||
// Preload script for deep-linked mode
|
||
(function preloadQueryModeScript() {
|
||
var queryMode = new URLSearchParams(window.location.search).get('mode');
|
||
var mode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
||
if (!mode) return;
|
||
window.ensureModeScript(mode).catch(function() {});
|
||
})();
|
||
</script>
|
||
</head>
|
||
|
||
<body data-mode="pager">
|
||
<!-- Welcome Page -->
|
||
<div class="welcome-overlay" id="welcomePage">
|
||
<!-- Spinning Globe Background -->
|
||
<div class="globe-background">
|
||
<svg class="globe-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Outer circle -->
|
||
<circle cx="200" cy="200" r="180" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||
<!-- Equator -->
|
||
<ellipse cx="200" cy="200" rx="180" ry="40" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||
<!-- Latitude lines -->
|
||
<ellipse cx="200" cy="140" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="260" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="90" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="310" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<!-- Prime meridian -->
|
||
<ellipse cx="200" cy="200" rx="40" ry="180" fill="none" stroke="currentColor" stroke-width="0.5" class="meridian meridian-1"/>
|
||
<!-- Additional meridians -->
|
||
<ellipse cx="200" cy="200" rx="100" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-2"/>
|
||
<ellipse cx="200" cy="200" rx="150" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-3"/>
|
||
<!-- Rotating meridian group -->
|
||
<g class="rotating-meridians">
|
||
<ellipse cx="200" cy="200" rx="70" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="200" rx="130" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||
<ellipse cx="200" cy="200" rx="170" ry="180" fill="none" stroke="currentColor" stroke-width="0.2"/>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
<div class="welcome-container">
|
||
<!-- Header Section -->
|
||
<div class="welcome-header">
|
||
<div class="welcome-logo">
|
||
<svg width="60" height="60" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none"
|
||
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
|
||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
|
||
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
|
||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none"
|
||
stroke-linecap="round" class="signal-wave signal-wave-3" />
|
||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none"
|
||
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
|
||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
|
||
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
|
||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none"
|
||
stroke-linecap="round" class="signal-wave signal-wave-3" />
|
||
<circle cx="50" cy="22" r="6" fill="#00ff88" class="logo-dot" />
|
||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
|
||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
</svg>
|
||
</div>
|
||
<h1 class="welcome-title"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
|
||
<p class="welcome-tagline">// See the Invisible</p>
|
||
<span class="welcome-version">v{{ version }}</span>
|
||
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Main Content Grid -->
|
||
<div class="welcome-content">
|
||
<!-- Left: Changelog -->
|
||
<div class="welcome-changelog">
|
||
<h2>What's New</h2>
|
||
{% for release in changelog[:2] %}
|
||
<div class="changelog-release">
|
||
<div class="changelog-version-header">
|
||
<span class="changelog-version">v{{ release.version }}</span>
|
||
<span class="changelog-date">{{ release.date }}</span>
|
||
</div>
|
||
<ul class="changelog-list">
|
||
{% for item in release.highlights %}
|
||
<li>{{ item }}</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Right: Mode Selection -->
|
||
<div class="welcome-modes">
|
||
<h2>Select Mode</h2>
|
||
|
||
<!-- Signals -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span> Signals</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('pager')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>
|
||
<span class="mode-name">Pager</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sensor')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||
<span class="mode-name">433MHz</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('rtlamr')">
|
||
<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="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>
|
||
<span class="mode-name">Meters</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('subghz')">
|
||
<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="M2 12h6l3-9 3 18 3-9h5"/></svg></span>
|
||
<span class="mode-name">SubGHz</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('waterfall')">
|
||
<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="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
|
||
<span class="mode-name">Waterfall</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('morse')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
|
||
<span class="mode-name">Morse</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('ook')">
|
||
<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="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg></span>
|
||
<span class="mode-name">OOK Decoder</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tracking -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span> Tracking</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<a href="/adsb/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||
<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="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>
|
||
<span class="mode-name">Aircraft</span>
|
||
</a>
|
||
<a href="/ais/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||
<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="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/></svg></span>
|
||
<span class="mode-name">Vessels</span>
|
||
</a>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
|
||
<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="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="mode-name">APRS</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('gps')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
|
||
<span class="mode-name">GPS</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('radiosonde')">
|
||
<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="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg></span>
|
||
<span class="mode-name">Radiosonde</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Space -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||
<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="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="mode-name">Satellite</span>
|
||
</a>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
|
||
<span class="mode-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"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
|
||
<span class="mode-name">ISS SSTV</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
|
||
<span class="mode-icon icon"><svg 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"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||
<span class="mode-name">Weather Sat</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv_general')">
|
||
<span class="mode-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"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||
<span class="mode-name">HF SSTV</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('wefax')">
|
||
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||
<span class="mode-name">WeFax</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||
<span class="mode-name">Space Wx</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Wireless -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span> Wireless</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('wifi')">
|
||
<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="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||
<span class="mode-name">WiFi</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('bluetooth')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||
<span class="mode-name">Bluetooth</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('bt_locate')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span>
|
||
<span class="mode-name">BT Locate</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('wifi_locate')">
|
||
<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="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></span>
|
||
<span class="mode-name">WF Locate</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('meshtastic')">
|
||
<span class="mode-icon icon"><svg 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"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
|
||
<span class="mode-name">Meshtastic</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intel -->
|
||
<div class="mode-category">
|
||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span> Intel</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('tscm')">
|
||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||
<span class="mode-name">TSCM</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" 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"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||
<span class="mode-name">Spy Stations</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('websdr')">
|
||
<span class="mode-icon icon"><svg 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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||
<span class="mode-name">WebSDR</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div class="welcome-footer">
|
||
<p>Signal Intelligence & Counter Surveillance Platform</p>
|
||
<a href="https://www.smittix.net" target="_blank" rel="noopener noreferrer" class="welcome-footer-credit">By Smittix</a>
|
||
</div>
|
||
</div>
|
||
<div class="welcome-scanline"></div>
|
||
</div>
|
||
|
||
<!-- Disclaimer Modal -->
|
||
<div class="disclaimer-overlay" id="disclaimerModal" style="display: none;">
|
||
<div class="disclaimer-modal">
|
||
<div class="warning-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||
<h2>DISCLAIMER</h2>
|
||
<p>
|
||
<strong>iNTERCEPT</strong> is a signal intelligence tool designed for <strong>educational purposes
|
||
only</strong>.
|
||
</p>
|
||
<p>By using this software, you acknowledge and agree that:</p>
|
||
<ul>
|
||
<li>This tool is intended for use by <strong>cyber security professionals</strong> and researchers only
|
||
</li>
|
||
<li>You will only use this software in a <strong>controlled environment</strong> with proper
|
||
authorization</li>
|
||
<li>Intercepting communications without consent may be <strong>illegal</strong> in your jurisdiction
|
||
</li>
|
||
<li>You are solely responsible for ensuring compliance with all applicable laws and regulations</li>
|
||
<li>The developers assume no liability for misuse of this software</li>
|
||
</ul>
|
||
<p style="color: var(--accent-red); font-weight: bold;">
|
||
Only proceed if you understand and accept these terms.
|
||
</p>
|
||
<div style="display: flex; gap: 15px; justify-content: center; margin-top: 20px;">
|
||
<button class="accept-btn" onclick="acceptDisclaimer()">I UNDERSTAND & ACCEPT</button>
|
||
<button class="accept-btn" onclick="declineDisclaimer()"
|
||
style="background: transparent; border: 1px solid var(--accent-red); color: var(--accent-red);">DECLINE</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TSCM Device Details Modal -->
|
||
<div class="tscm-modal-overlay" id="tscmDeviceModal" style="display: none;"
|
||
onclick="if(event.target === this) closeTscmDeviceModal()">
|
||
<div class="tscm-modal">
|
||
<button class="tscm-modal-close" onclick="closeTscmDeviceModal()">×</button>
|
||
<div id="tscmDeviceModalContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rejection Page -->
|
||
<div class="disclaimer-overlay disclaimer-hidden" id="rejectionPage">
|
||
<div class="disclaimer-modal" style="max-width: 600px;">
|
||
<pre
|
||
style="color: var(--accent-red); font-size: 9px; line-height: 1.1; margin-bottom: 20px; text-align: center;">
|
||
█████╗ ██████╗ ██████╗███████╗███████╗███████╗
|
||
██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
|
||
███████║██║ ██║ █████╗ ███████╗███████╗
|
||
██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
|
||
██║ ██║╚██████╗╚██████╗███████╗███████║███████║
|
||
╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
|
||
██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
|
||
██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
|
||
██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
|
||
██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
|
||
██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
|
||
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
|
||
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
|
||
<p
|
||
style="font-family: var(--font-mono); font-size: 11px; color: #888; text-align: left; margin: 0;">
|
||
<span style="color: var(--accent-red);">root@intercepted:</span><span
|
||
style="color: var(--accent-cyan);">~#</span> sudo access --grant-permission<br>
|
||
<span style="color: #666;">[sudo] password for user: ********</span><br>
|
||
<span style="color: var(--accent-red);">Error:</span> User is not in the sudoers file.<br>
|
||
<span style="color: var(--accent-orange);">This incident will be reported.</span>
|
||
</p>
|
||
</div>
|
||
<p style="color: #666; font-size: 11px; text-align: center;">
|
||
"In a world of locked doors, the man with the key is king.<br>
|
||
And you, my friend, just threw away the key."
|
||
</p>
|
||
<button class="accept-btn" onclick="location.reload()"
|
||
style="margin-top: 20px; background: transparent; border: 1px solid var(--accent-cyan); color: var(--accent-cyan);">
|
||
TRY AGAIN
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<header>
|
||
<div class="header-left">
|
||
<!-- Hamburger Menu Button (Mobile) -->
|
||
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</button>
|
||
<a href="https://smittix.github.io/intercept" target="_blank" rel="noopener noreferrer" class="logo">
|
||
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Signal brackets - left side -->
|
||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
|
||
opacity="0.5" />
|
||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||
opacity="0.7" />
|
||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
|
||
<!-- Signal brackets - right side -->
|
||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
|
||
opacity="0.5" />
|
||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
|
||
opacity="0.7" />
|
||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
|
||
<!-- The 'i' letter -->
|
||
<!-- dot of i -->
|
||
<circle cx="50" cy="22" r="6" fill="#00ff88" />
|
||
<!-- stem of i with styled terminals -->
|
||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
|
||
<!-- top terminal bar -->
|
||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
<!-- bottom terminal bar -->
|
||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
|
||
</svg>
|
||
</a>
|
||
<h1><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT <span class="tagline">// See the Invisible</span></h1>
|
||
</div>
|
||
<div class="header-right">
|
||
<span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span>
|
||
<span class="version-badge">v{{ version }}</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div id="runStateStrip" class="run-state-strip" aria-live="polite">
|
||
<div class="run-state-left">
|
||
<span class="run-state-label">Run State</span>
|
||
<div id="runStateChips"></div>
|
||
</div>
|
||
<div class="run-state-right">
|
||
<span id="runStateSummary" class="run-state-value">Loading...</span>
|
||
<button id="runStateRefreshBtn" class="run-state-btn" type="button">Refresh</button>
|
||
<button id="runStateSettingsBtn" class="run-state-btn" type="button">Tools</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode Navigation Bar -->
|
||
{% set is_index_page = true %}
|
||
{% set active_mode = 'pager' %}
|
||
{% include 'partials/nav.html' with context %}
|
||
|
||
<!-- Mobile Drawer Overlay -->
|
||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||
|
||
<div class="container">
|
||
<div class="main-content">
|
||
<div class="sidebar mobile-drawer" id="mainSidebar">
|
||
<button class="sidebar-collapse-btn" id="sidebarCollapseBtn" onclick="toggleMainSidebarCollapse()" title="Collapse sidebar">
|
||
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></span>
|
||
Collapse Sidebar
|
||
</button>
|
||
<!-- Agent Selector -->
|
||
<div class="section" id="agentSection">
|
||
<h3>Signal Source</h3>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Agent</label>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<select id="agentSelect" style="flex: 1;">
|
||
<option value="local">Local (This Device)</option>
|
||
</select>
|
||
<span id="agentStatusDot" class="agent-status-dot online" title="Agent status"></span>
|
||
</div>
|
||
</div>
|
||
<div id="agentInfo" class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
|
||
<span id="agentStatusText">Local</span>
|
||
<span id="agentLatencyText" style="margin-left: 6px; color: var(--accent-cyan);"></span>
|
||
</div>
|
||
<!-- Agent health panel (shows all agents when expanded) -->
|
||
<details style="margin-top: 8px;">
|
||
<summary style="font-size: 10px; color: #888; cursor: pointer;">All Agents Health</summary>
|
||
<div id="agentHealthPanel" style="margin-top: 6px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; max-height: 120px; overflow-y: auto;">
|
||
<div style="color: var(--text-muted); font-size: 11px;">Loading...</div>
|
||
</div>
|
||
</details>
|
||
<!-- Multi-agent mode toggle -->
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label class="inline-checkbox" style="display: flex; align-items: center; gap: 8px;">
|
||
<input type="checkbox" id="showAllAgents" onchange="toggleMultiAgentMode()">
|
||
<span style="font-size: 11px;">Show All Agents Combined</span>
|
||
</label>
|
||
</div>
|
||
<a href="/controller/manage" class="preset-btn" style="display: block; text-align: center; text-decoration: none; margin-top: 8px; font-size: 11px;">
|
||
Manage Agents
|
||
</a>
|
||
</div>
|
||
|
||
<div class="section" id="rtlDeviceSection">
|
||
<h3>SDR Device</h3>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
|
||
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
|
||
<option value="rtlsdr">RTL-SDR</option>
|
||
<option value="sdrplay">SDRplay</option>
|
||
<option value="limesdr">LimeSDR</option>
|
||
<option value="hackrf">HackRF</option>
|
||
<option value="airspy">Airspy</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Device</label>
|
||
<select id="deviceSelect">
|
||
{% if devices %}
|
||
{% for device in devices %}
|
||
<option value="{{ device.index }}"
|
||
data-sdr-type="{{ device.sdr_type | default('rtlsdr') }}">{{ device.index }}: {{ device.name }}{% if device.serial and device.serial != 'N/A' and device.serial != 'Unknown' %} (SN: {{ device.serial }}){% endif %}</option>
|
||
{% endfor %}
|
||
{% else %}
|
||
<option value="0">No devices found</option>
|
||
{% endif %}
|
||
</select>
|
||
</div>
|
||
<div id="deviceCapabilities" class="info-text"
|
||
style="font-size: 11px; margin-bottom: 8px; padding: 6px; background: #0a0a1a; border-radius: 4px;">
|
||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||
<span style="color: #888;">Freq:</span><span id="capFreqRange">24-1766 MHz</span>
|
||
<span style="color: #888;">Gain:</span><span id="capGainRange">0-50 dB</span>
|
||
</div>
|
||
</div>
|
||
<!-- Bias-T Power Toggle - Prominent Location -->
|
||
<div
|
||
style="display: flex; align-items: center; gap: 10px; padding: 8px; margin-bottom: 8px; background: linear-gradient(90deg, rgba(255,100,0,0.2), rgba(255,100,0,0.05)); border: 1px solid var(--accent-orange); border-radius: 4px;">
|
||
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()"
|
||
style="width: 18px; height: 18px; accent-color: var(--accent-orange);">
|
||
<div>
|
||
<div style="color: var(--accent-orange); font-weight: bold; font-size: 12px;">Bias-T Power
|
||
</div>
|
||
<div style="color: #888; font-size: 9px;">Powers external LNA/preamp</div>
|
||
</div>
|
||
</div>
|
||
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
|
||
Refresh Devices
|
||
</button>
|
||
|
||
<!-- SDR Device Status -->
|
||
<div id="sdrStatusPanel" style="margin-top: 10px; border: 1px solid var(--border-color); border-radius: 4px;">
|
||
<div id="sdrStatusList" style="max-height: 150px; overflow-y: auto;"></div>
|
||
<div style="padding: 6px 8px; background: var(--bg-tertiary); border-top: 1px solid var(--border-color); font-size: 10px; color: #666;">
|
||
Auto-refreshes every 5s
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Remote SDR (rtl_tcp) -->
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label class="inline-checkbox">
|
||
<input type="checkbox" id="useRemoteSDR" onchange="toggleRemoteSDR()">
|
||
Use Remote SDR (rtl_tcp)
|
||
</label>
|
||
</div>
|
||
<div id="remoteSDRConfig" style="display: none; margin-bottom: 10px;">
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888;">Host</label>
|
||
<input type="text" id="rtlTcpHost" placeholder="192.168.1.100" style="width: 100%;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label style="font-size: 11px; color: #888;">Port</label>
|
||
<input type="number" id="rtlTcpPort" value="1234" min="1" max="65535" style="width: 100%;">
|
||
</div>
|
||
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
|
||
Connect to rtl_tcp server on remote machine.<br>
|
||
Start server with: <code style="color: #00d4ff;">rtl_tcp -a 0.0.0.0</code>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toolStatusPager" class="info-text tool-status-section">
|
||
<span>rtl_fm:</span><span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK'
|
||
if tools.rtl_fm else 'Missing' }}</span>
|
||
<span>multimon-ng:</span><span
|
||
class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon
|
||
else 'Missing' }}</span>
|
||
</div>
|
||
<div id="toolStatusSensor" class="info-text tool-status-section">
|
||
<span>rtl_433:</span><span class="tool-status {{ 'ok' if tools.rtl_433 else 'missing' }}">{{
|
||
'OK' if tools.rtl_433 else 'Missing' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{% include 'partials/modes/pager.html' %}
|
||
|
||
{% include 'partials/modes/sensor.html' %}
|
||
|
||
{% include 'partials/modes/rtlamr.html' %}
|
||
|
||
{% include 'partials/modes/wifi.html' %}
|
||
|
||
{% include 'partials/modes/bluetooth.html' %}
|
||
|
||
{% include 'partials/modes/aprs.html' %}
|
||
|
||
{% include 'partials/modes/satellite.html' %}
|
||
|
||
{% include 'partials/modes/sstv.html' %}
|
||
|
||
{% include 'partials/modes/weather-satellite.html' %}
|
||
|
||
{% include 'partials/modes/sstv-general.html' %}
|
||
|
||
{% include 'partials/modes/gps.html' %}
|
||
|
||
{% include 'partials/modes/wefax.html' %}
|
||
|
||
{% include 'partials/modes/morse.html' %}
|
||
|
||
{% include 'partials/modes/ook.html' %}
|
||
|
||
{% include 'partials/modes/space-weather.html' %}
|
||
|
||
{% include 'partials/modes/tscm.html' %}
|
||
|
||
{% include 'partials/modes/ais.html' %}
|
||
|
||
{% include 'partials/modes/radiosonde.html' %}
|
||
|
||
{% include 'partials/modes/spy-stations.html' %}
|
||
|
||
{% include 'partials/modes/meshtastic.html' %}
|
||
|
||
{% include 'partials/modes/websdr.html' %}
|
||
|
||
{% include 'partials/modes/subghz.html' %}
|
||
|
||
{% include 'partials/modes/bt_locate.html' %}
|
||
{% include 'partials/modes/wifi_locate.html' %}
|
||
{% include 'partials/modes/waterfall.html' %}
|
||
{% include 'partials/modes/meteor.html' %}
|
||
{% include 'partials/modes/system.html' %}
|
||
|
||
|
||
|
||
<button class="preset-btn" onclick="killAll()"
|
||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||
Kill All Processes
|
||
</button>
|
||
</div>
|
||
|
||
<div class="output-panel">
|
||
<button class="sidebar-expand-handle" id="sidebarExpandHandle" onclick="toggleMainSidebarCollapse()" title="Expand sidebar">
|
||
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||
</button>
|
||
<div class="output-header">
|
||
<h3 id="outputTitle">Pager Decoder</h3>
|
||
<div class="header-controls">
|
||
<div class="stats" id="pagerStats">
|
||
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
|
||
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
|
||
<div title="FLEX Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="flexCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="sensorStats">
|
||
<div title="Unique Sensors"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> <span id="sensorCount">0</span></div>
|
||
<div title="Device Types"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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></span> <span id="deviceCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="wifiStats">
|
||
<div title="Access Points"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> <span id="apCount">0</span></div>
|
||
<div title="Connected Clients"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span> <span id="clientCount">0</span></div>
|
||
<div title="Captured Handshakes" style="color: var(--accent-green);"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg></span> <span id="handshakeCount">0</span></div>
|
||
<div style="color: var(--accent-orange); cursor: pointer;" onclick="showDroneDetails()"
|
||
title="Click: Drone details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M3 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M9 9l-4 -1"/><path d="M15 9l4 -1"/><path d="M9 15l-4 1"/><path d="M15 15l4 1"/></svg></span> <span id="droneCount">0</span></div>
|
||
<div style="color: var(--accent-red); cursor: pointer;" onclick="showRogueApDetails()"
|
||
title="Click: Rogue AP details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span> <span id="rogueApCount">0</span></div>
|
||
</div>
|
||
<div class="stats" id="satelliteStats">
|
||
<div title="Upcoming Passes"><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> <span id="passCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Layout Container -->
|
||
<div class="wifi-layout-container" id="wifiLayoutContainer">
|
||
<!-- Status Bar -->
|
||
<div class="wifi-status-bar">
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Networks:</span>
|
||
<span class="wifi-status-value" id="wifiNetworkCount">0</span>
|
||
</div>
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Clients:</span>
|
||
<span class="wifi-status-value" id="wifiClientCount">0</span>
|
||
</div>
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Hidden:</span>
|
||
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
|
||
</div>
|
||
<div class="wifi-status-item">
|
||
<span class="wifi-status-label">Open:</span>
|
||
<span class="wifi-status-value" id="wifiOpenCount" style="color:var(--accent-red);">0</span>
|
||
</div>
|
||
<div class="wifi-scan-indicator" id="wifiScanIndicator">
|
||
<span class="wifi-scan-dot"></span>
|
||
<span class="wifi-scan-text">IDLE</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content: 3-column layout -->
|
||
<div class="wifi-main-content">
|
||
<!-- LEFT: Networks List -->
|
||
<div class="wifi-networks-panel">
|
||
<div class="wifi-networks-header">
|
||
<h5>Discovered Networks</h5>
|
||
<div class="wifi-network-filters" id="wifiNetworkFilters">
|
||
<button class="wifi-filter-btn active" data-filter="all">All</button>
|
||
<button class="wifi-filter-btn" data-filter="2.4">2.4G</button>
|
||
<button class="wifi-filter-btn" data-filter="5">5G</button>
|
||
<button class="wifi-filter-btn" data-filter="open">Open</button>
|
||
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
|
||
</div>
|
||
<div class="wifi-sort-controls">
|
||
<span class="wifi-sort-label">Sort:</span>
|
||
<button class="wifi-sort-btn active" data-sort="rssi">Signal</button>
|
||
<button class="wifi-sort-btn" data-sort="essid">SSID</button>
|
||
<button class="wifi-sort-btn" data-sort="channel">Ch</button>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-networks-table-wrapper">
|
||
<div id="wifiNetworkList" class="wifi-network-list">
|
||
<div class="wifi-network-placeholder">
|
||
<p>No networks detected.<br>Start a scan to begin.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CENTER: Proximity Radar -->
|
||
<div class="wifi-radar-panel">
|
||
<h5>Proximity Radar</h5>
|
||
<div id="wifiProximityRadar" class="wifi-radar-container">
|
||
<svg width="100%" viewBox="0 0 210 210" id="wifiRadarSvg">
|
||
<defs>
|
||
<clipPath id="wifi-radar-clip">
|
||
<circle cx="105" cy="105" r="100"/>
|
||
</clipPath>
|
||
<filter id="wifi-glow-sm">
|
||
<feGaussianBlur stdDeviation="2.5" result="blur"/>
|
||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
<filter id="wifi-glow-md">
|
||
<feGaussianBlur stdDeviation="4" result="blur"/>
|
||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
</defs>
|
||
|
||
<!-- Background rings (static) -->
|
||
<circle cx="105" cy="105" r="100" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.12"/>
|
||
<circle cx="105" cy="105" r="70" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.18"/>
|
||
<circle cx="105" cy="105" r="40" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.25"/>
|
||
<circle cx="105" cy="105" r="15" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.35"/>
|
||
|
||
<!-- Crosshairs -->
|
||
<line x1="5" y1="105" x2="205" y2="105" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
|
||
<line x1="105" y1="5" x2="105" y2="205" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
|
||
|
||
<!-- Rotating sweep group -->
|
||
<g class="wifi-radar-sweep" clip-path="url(#wifi-radar-clip)">
|
||
<!-- Primary trailing arc: 60° -->
|
||
<path d="M105,105 L105,5 A100,100 0 0,1 191.6,155 Z" fill="#00b4d8" opacity="0.08"/>
|
||
<!-- Secondary trailing arc: 90° -->
|
||
<path d="M105,105 L105,5 A100,100 0 0,1 205,105 Z" fill="#00b4d8" opacity="0.04"/>
|
||
<!-- Sweep line -->
|
||
<line x1="105" y1="105" x2="105" y2="5" stroke="#00b4d8" stroke-width="1.5" opacity="0.7"
|
||
filter="url(#wifi-glow-sm)"/>
|
||
</g>
|
||
|
||
<!-- Centre dot -->
|
||
<circle cx="105" cy="105" r="3" fill="#00b4d8" opacity="0.8"/>
|
||
|
||
<!-- Network dots (managed by renderRadar()) -->
|
||
<g id="wifiRadarDots"></g>
|
||
</svg>
|
||
</div>
|
||
<div class="wifi-zone-summary">
|
||
<div class="wifi-zone near">
|
||
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
|
||
<span class="wifi-zone-label">Close</span>
|
||
</div>
|
||
<div class="wifi-zone mid">
|
||
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
|
||
<span class="wifi-zone-label">Mid</span>
|
||
</div>
|
||
<div class="wifi-zone far">
|
||
<span class="wifi-zone-count" id="wifiZoneFar">0</span>
|
||
<span class="wifi-zone-label">Far</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Channel Heatmap + Security Ring -->
|
||
<div class="wifi-analysis-panel">
|
||
<div class="wifi-analysis-panel-header">
|
||
<span class="panel-title" id="wifiRightPanelTitle">Channel Heatmap</span>
|
||
<button class="wifi-detail-back-btn" id="wifiDetailBackBtn"
|
||
style="display:none" onclick="WiFiMode.closeDetail()">← Back</button>
|
||
</div>
|
||
|
||
<!-- Default: heatmap + security ring -->
|
||
<div id="wifiHeatmapView" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
|
||
<div class="wifi-heatmap-wrap">
|
||
<div class="wifi-heatmap-label">
|
||
2.4 GHz · Last <span id="wifiHeatmapCount">0</span> scans
|
||
</div>
|
||
<div class="wifi-heatmap-ch-labels" id="wifiHeatmapChLabels">
|
||
<!-- 11 channel labels (1–11), generated once by JS -->
|
||
</div>
|
||
<div class="wifi-heatmap-grid" id="wifiHeatmapGrid"></div>
|
||
<div class="wifi-heatmap-legend">
|
||
<span>Low</span>
|
||
<div class="wifi-heatmap-legend-grad"></div>
|
||
<span>High</span>
|
||
</div>
|
||
</div>
|
||
<div class="wifi-security-ring-wrap">
|
||
<svg id="wifiSecurityRingSvg" viewBox="0 0 48 48" width="48" height="48">
|
||
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
|
||
</svg>
|
||
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- On network click: detail panel (wired in Task 5) -->
|
||
<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">
|
||
<div class="wifi-detail-inner">
|
||
<div class="wifi-detail-head">
|
||
<div class="wifi-detail-essid" id="wifiDetailEssid">—</div>
|
||
<div class="wifi-detail-bssid" id="wifiDetailBssid">—</div>
|
||
</div>
|
||
|
||
<div class="wifi-detail-signal-bar">
|
||
<div class="wifi-detail-signal-labels">
|
||
<span>Signal</span>
|
||
<span id="wifiDetailRssi">—</span>
|
||
</div>
|
||
<div class="wifi-detail-signal-track">
|
||
<div class="wifi-detail-signal-fill" id="wifiDetailSignalFill" style="width:0%"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wifi-detail-grid">
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Channel</span>
|
||
<span class="value" id="wifiDetailChannel">—</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Band</span>
|
||
<span class="value" id="wifiDetailBand">—</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Security</span>
|
||
<span class="value" id="wifiDetailSecurity">—</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Cipher</span>
|
||
<span class="value" id="wifiDetailCipher">—</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">Clients</span>
|
||
<span class="value" id="wifiDetailClients">—</span>
|
||
</div>
|
||
<div class="wifi-detail-stat">
|
||
<span class="label">First Seen</span>
|
||
<span class="value" id="wifiDetailFirstSeen">—</span>
|
||
</div>
|
||
<div class="wifi-detail-stat" style="grid-column: 1 / -1;">
|
||
<span class="label">Vendor</span>
|
||
<span class="value" id="wifiDetailVendor" style="font-size:11px;">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
|
||
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
|
||
<div class="wifi-client-list"></div>
|
||
</div>
|
||
|
||
<div class="wifi-detail-actions">
|
||
<button class="wfl-locate-btn" title="Locate this AP"
|
||
onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="10" r="3"/>
|
||
<path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
|
||
</svg>
|
||
Locate
|
||
</button>
|
||
<button class="wifi-detail-close-btn" onclick="WiFiMode.closeDetail()">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Layout Container (visualizations left, device cards right) -->
|
||
<div class="bt-layout-container" id="btLayoutContainer">
|
||
<!-- Left: Bluetooth Visualizations -->
|
||
<div class="bt-visuals-column" id="btVisuals">
|
||
<!-- Device Detail Panel (always visible) -->
|
||
<div class="bt-detail-panel" id="btDetailPanel">
|
||
<div class="bt-detail-header">
|
||
<h5>Device Details</h5>
|
||
</div>
|
||
<div class="bt-detail-body">
|
||
<!-- Placeholder shown when no device selected -->
|
||
<div class="bt-detail-placeholder" id="btDetailPlaceholder">
|
||
<span>Select a device to view details</span>
|
||
</div>
|
||
<!-- Content shown when device is selected -->
|
||
<div class="bt-detail-content" id="btDetailContent" style="display: none;">
|
||
<div class="bt-detail-top-row">
|
||
<div class="bt-detail-identity">
|
||
<div class="bt-detail-name" id="btDetailName">Device Name</div>
|
||
<div class="bt-detail-address">
|
||
<span id="btDetailAddress">00:00:00:00:00:00</span>
|
||
<span class="bt-mac-cluster-badge" id="btDetailMacCluster" style="display:none;"></span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-rssi-display">
|
||
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
|
||
<span class="bt-detail-rssi-unit">dBm</span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-badges" id="btDetailBadges"></div>
|
||
<div class="bt-detail-tracker-analysis" id="btDetailTrackerAnalysis" style="display: none;"></div>
|
||
<div class="bt-detail-grid">
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Manufacturer</span>
|
||
<span class="bt-detail-stat-value" id="btDetailMfr">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Type</span>
|
||
<span class="bt-detail-stat-value" id="btDetailAddrType">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Seen</span>
|
||
<span class="bt-detail-stat-value" id="btDetailSeen">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Range</span>
|
||
<span class="bt-detail-stat-value" id="btDetailRange">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Min/Max</span>
|
||
<span class="bt-detail-stat-value" id="btDetailRssiRange">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">First Seen</span>
|
||
<span class="bt-detail-stat-value" id="btDetailFirstSeen">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Last Seen</span>
|
||
<span class="bt-detail-stat-value" id="btDetailLastSeen">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Mfr ID</span>
|
||
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">TX Power</span>
|
||
<span class="bt-detail-stat-value" id="btDetailTxPower">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Seen Rate</span>
|
||
<span class="bt-detail-stat-value" id="btDetailSeenRate">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Stability</span>
|
||
<span class="bt-detail-stat-value" id="btDetailStability">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Distance</span>
|
||
<span class="bt-detail-stat-value" id="btDetailDistance">--</span>
|
||
</div>
|
||
</div>
|
||
<!-- Service Data Inspector (collapsible) -->
|
||
<div class="bt-detail-service-inspector" id="btDetailServiceInspector" style="display:none;">
|
||
<div class="bt-inspector-toggle" onclick="BluetoothMode.toggleServiceInspector()">
|
||
<span class="bt-inspector-arrow" id="btInspectorArrow">▸</span> Raw Data
|
||
</div>
|
||
<div class="bt-inspector-content" id="btInspectorContent" style="display:none;">
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-bottom-row">
|
||
<div class="bt-detail-irk" id="btDetailIrk" style="display: none;">
|
||
<span class="bt-irk-badge">IRK</span>
|
||
<span class="bt-detail-irk-value" id="btDetailIrkValue" style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);margin-left:6px;word-break:break-all;"></span>
|
||
</div>
|
||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||
</div>
|
||
<button class="bt-detail-btn" id="btDetailWatchBtn" onclick="BluetoothMode.toggleWatchlist()">Watchlist</button>
|
||
<button class="bt-detail-btn" id="btDetailCopyBtn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||
<button class="bt-detail-btn bt-locate-btn" id="btDetailLocateBtn" onclick="BluetoothMode.locateDevice()">Locate</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Main area: Side panels + Radar -->
|
||
<div class="bt-main-area">
|
||
<!-- Left side panels -->
|
||
<div class="bt-side-panels">
|
||
<div class="wifi-visual-panel bt-side-panel bt-tracker-panel">
|
||
<h5>Tracker Detection</h5>
|
||
<div id="btTrackerList" class="bt-tracker-list">
|
||
<div class="app-collection-state is-empty">Monitoring for AirTags, Tiles...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Proximity Radar -->
|
||
<div class="wifi-visual-panel bt-radar-panel">
|
||
<h5>Proximity Radar</h5>
|
||
<div id="btProximityRadar" style="display: flex; justify-content: center; padding: 8px 0;"></div>
|
||
<div id="btRadarControls" style="display: flex; gap: 6px; justify-content: center; margin-top: 8px; flex-wrap: wrap;">
|
||
<button data-filter="newOnly" class="bt-radar-filter-btn">New Only</button>
|
||
<button data-filter="strongest" class="bt-radar-filter-btn">Strongest</button>
|
||
<button data-filter="unapproved" class="bt-radar-filter-btn">Unapproved</button>
|
||
<button id="btRadarPauseBtn">Pause</button>
|
||
</div>
|
||
<div id="btZoneSummary" class="bt-zone-summary">
|
||
<div class="bt-zone-card immediate">
|
||
<span id="btZoneImmediate" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Immediate</div>
|
||
</div>
|
||
<div class="bt-zone-card near">
|
||
<span id="btZoneNear" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Near</div>
|
||
</div>
|
||
<div class="bt-zone-card far">
|
||
<span id="btZoneFar" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Far</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Right: Bluetooth Device Cards -->
|
||
<div class="wifi-device-list bt-device-list" id="btDeviceListPanel">
|
||
<div class="wifi-device-list-header">
|
||
<h5>Bluetooth Devices</h5>
|
||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||
<div class="bt-scan-indicator" id="btScanIndicator">
|
||
<span class="bt-scan-dot" style="display:none;"></span>
|
||
<span class="bt-scan-text">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-list-summary" id="btListSummary">
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Total</span>
|
||
<span class="bt-summary-value" id="btSummaryTotal">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">New</span>
|
||
<span class="bt-summary-value" id="btSummaryNew">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Trackers</span>
|
||
<span class="bt-summary-value" id="btSummaryTrackers">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Strongest</span>
|
||
<span class="bt-summary-value" id="btSummaryStrongest">--</span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-list-signal-strip">
|
||
<div class="bt-list-signal-title">Signal Distribution</div>
|
||
<div class="bt-signal-dist bt-signal-dist-compact" id="btSignalDist">
|
||
<div class="signal-range"><span>Strong</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
|
||
</div><span id="btSignalStrongCount">0</span>
|
||
</div>
|
||
<div class="signal-range"><span>Medium</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
|
||
</div><span id="btSignalMediumCount">0</span>
|
||
</div>
|
||
<div class="signal-range"><span>Weak</span>
|
||
<div class="signal-bar-bg">
|
||
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
|
||
</div><span id="btSignalWeakCount">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bt-controls-row">
|
||
<div class="bt-sort-group" id="btSortGroup">
|
||
<span class="bt-sort-label">Sort</span>
|
||
<button class="bt-sort-btn active" data-sort="rssi">Signal</button>
|
||
<button class="bt-sort-btn" data-sort="name">Name</button>
|
||
<button class="bt-sort-btn" data-sort="seen">Seen</button>
|
||
<button class="bt-sort-btn" data-sort="distance">Dist</button>
|
||
</div>
|
||
<div class="bt-filter-group" id="btFilterGroup">
|
||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||
</div>
|
||
</div>
|
||
<div class="bt-device-toolbar">
|
||
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
|
||
</div>
|
||
<div class="wifi-device-list-content" id="btDeviceListContent">
|
||
<div class="app-collection-state is-empty">Start scanning to discover Bluetooth devices</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Device Detail Modal -->
|
||
<div id="btDeviceModal" class="bt-modal-overlay" style="display: none;">
|
||
<div class="bt-modal">
|
||
<div class="bt-modal-header">
|
||
<h4 id="btModalTitle">Device Details</h4>
|
||
<button class="bt-modal-close" onclick="BluetoothMode.closeModal()">×</button>
|
||
</div>
|
||
<div class="bt-modal-body" id="btModalBody">
|
||
<!-- Populated by JavaScript -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- APRS Visualizations -->
|
||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px; overflow: hidden; min-height: 0;">
|
||
<!-- APRS Function Bar -->
|
||
<div class="aprs-strip">
|
||
<div class="aprs-strip-inner">
|
||
<!-- Stats -->
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripFreq">--</span>
|
||
<span class="strip-label">MHz</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripStations">0</span>
|
||
<span class="strip-label">STATIONS</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="aprsStripPackets">0</span>
|
||
<span class="strip-label">PACKETS</span>
|
||
</div>
|
||
<div class="strip-stat signal-stat" id="aprsStripSignalStat">
|
||
<span class="strip-value" id="aprsStripSignal">--</span>
|
||
<span class="strip-label">SIGNAL</span>
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<!-- Controls -->
|
||
<div class="strip-control">
|
||
<select id="aprsStripRegion" class="strip-select">
|
||
<option value="north_america">N. America (144.390)</option>
|
||
<option value="europe">Europe (144.800)</option>
|
||
<option value="uk">UK (144.800)</option>
|
||
<option value="australia">Australia (145.175)</option>
|
||
<option value="new_zealand">New Zealand (144.575)</option>
|
||
<option value="argentina">Argentina (144.930)</option>
|
||
<option value="brazil">Brazil (145.570)</option>
|
||
<option value="japan">Japan (144.640)</option>
|
||
<option value="china">China (144.640)</option>
|
||
<option value="iss">ISS (145.825)</option>
|
||
<option value="sonate2">SONATE-2 (145.825)</option>
|
||
<option value="custom">Custom Frequency</option>
|
||
</select>
|
||
</div>
|
||
<div class="strip-control" id="aprsStripCustomFreqControl" style="display: none;">
|
||
<span class="strip-input-label">FREQ (MHz)</span>
|
||
<input type="number" id="aprsStripCustomFreq" class="strip-input" placeholder="144.390" step="0.001" min="144" max="146">
|
||
</div>
|
||
<div class="strip-control">
|
||
<span class="strip-input-label">GAIN</span>
|
||
<input type="number" id="aprsStripGain" class="strip-input" value="40" min="0" max="50">
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<!-- Tool Status Indicators -->
|
||
<div class="strip-tools">
|
||
<span class="strip-tool" id="aprsStripDirewolf" title="direwolf">DW</span>
|
||
<span class="strip-tool" id="aprsStripMultimon" title="multimon-ng">MM</span>
|
||
</div>
|
||
<span id="aprsGpsIndicator" class="gps-indicator" style="display: none;"
|
||
title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||
<div class="strip-divider"></div>
|
||
<!-- Actions -->
|
||
<button type="button" class="strip-btn primary" id="aprsStripStartBtn"
|
||
onclick="startAprs()">
|
||
▶ START
|
||
</button>
|
||
<button type="button" class="strip-btn stop" id="aprsStripStopBtn" onclick="stopAprs()"
|
||
style="display: none;">
|
||
◼ STOP
|
||
</button>
|
||
<!-- Status -->
|
||
<div class="strip-status">
|
||
<div class="status-dot inactive" id="aprsStripDot"></div>
|
||
<span id="aprsStripStatus">STANDBY</span>
|
||
</div>
|
||
<div class="strip-time" id="aprsStripTime">--:--:-- UTC</div>
|
||
</div>
|
||
</div>
|
||
<!-- Top row: Map and Station List side by side -->
|
||
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
||
<!-- Map Panel (larger) -->
|
||
<div class="wifi-visual-panel"
|
||
style="flex: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;">
|
||
<h5
|
||
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
|
||
APRS STATION MAP</h5>
|
||
<div class="aircraft-map-container" style="flex: 1; display: flex; flex-direction: column;">
|
||
<div class="map-header">
|
||
<span id="aprsMapTime">--:--:--</span>
|
||
<span id="aprsMapStatus">STANDBY</span>
|
||
</div>
|
||
<div id="aprsMap" style="flex: 1; min-height: 350px;"></div>
|
||
<div class="map-footer">
|
||
<span>STATIONS: <span id="aprsStationCount">0</span></span>
|
||
<span>PACKETS: <span id="aprsPacketCount">0</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Station List Panel -->
|
||
<div class="wifi-visual-panel"
|
||
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||
<h5
|
||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
|
||
STATION LIST</h5>
|
||
<div id="aprsFilterBarContainer" style="flex-shrink: 0;"></div>
|
||
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px; min-height: 0;">
|
||
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
|
||
No stations received yet
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom row: Packet Log -->
|
||
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px; flex-shrink: 0;">
|
||
<h5
|
||
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
||
PACKET LOG</h5>
|
||
<div id="aprsPacketLog"
|
||
style="flex: 1; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
|
||
<div style="color: var(--text-muted);">Waiting for packets...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GPS Receiver Dashboard -->
|
||
<div id="gpsVisuals" class="gps-visuals-container" style="display: none;">
|
||
<div class="gps-visuals-top">
|
||
<!-- Sky View Globe -->
|
||
<div class="gps-skyview-panel">
|
||
<h4>Satellite Globe View</h4>
|
||
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
|
||
<div id="gpsSkyGlobe" class="gps-sky-globe" aria-label="GPS satellite globe"></div>
|
||
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky fallback globe"></canvas>
|
||
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
|
||
</div>
|
||
<div class="gps-sky-hint">Drag to orbit globe | Scroll to zoom | Hover satellites for details</div>
|
||
<div class="gps-legend">
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffffff;"></span> Observer</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff8800;"></span> Galileo</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff4466;"></span> BeiDou</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffdd00;"></span> SBAS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#cc66ff;"></span> QZSS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (bright)</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:rgba(0,212,255,0.45);"></span> Unused (dim)</div>
|
||
</div>
|
||
</div>
|
||
<!-- Position Info -->
|
||
<div class="gps-position-panel">
|
||
<h4>Position</h4>
|
||
<div class="gps-pos-big">
|
||
<div id="gpsVisPosLat">---</div>
|
||
<div id="gpsVisPosLon">---</div>
|
||
</div>
|
||
<div style="margin-top: 4px;">
|
||
<span class="gps-fix-badge no-fix" id="gpsVisFixBadge">NO FIX</span>
|
||
</div>
|
||
<div style="margin-top: 12px;">
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Altitude</span>
|
||
<span class="gps-pos-value" id="gpsVisPosAlt">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Speed</span>
|
||
<span class="gps-pos-value" id="gpsVisPosSpeed">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Heading</span>
|
||
<span class="gps-pos-value" id="gpsVisPosHeading">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Climb</span>
|
||
<span class="gps-pos-value" id="gpsVisPosClimb">---</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border-color);">
|
||
<div class="gps-pos-label">GPS TIME</div>
|
||
<div class="gps-pos-value" id="gpsVisTime" style="font-size: 14px; color: var(--accent-cyan);">---</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal Strength Bars -->
|
||
<div class="gps-signal-panel">
|
||
<h4>Signal Strength (SNR dB-Hz)</h4>
|
||
<div class="gps-signal-bars" id="gpsSignalBars"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Dashboard (Embedded) -->
|
||
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
||
<iframe id="satelliteDashboardFrame" data-src="/satellite/dashboard?embedded=true&v={{ version }}" frameborder="0"
|
||
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
|
||
loading="lazy"
|
||
allowfullscreen>
|
||
</iframe>
|
||
</div>
|
||
|
||
<!-- TSCM Dashboard -->
|
||
<div id="tscmVisuals" class="tscm-dashboard" style="display: none; padding: 16px;">
|
||
<!-- Legal Disclaimer Banner -->
|
||
<div class="tscm-legal-banner"
|
||
style="margin-bottom: 12px; padding: 8px 12px; background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; font-size: 10px; color: #ffffff;">
|
||
<strong>TSCM Screening Tool:</strong> This system identifies wireless and RF anomalies.
|
||
Findings are indicators, NOT confirmed surveillance devices.
|
||
No content is intercepted or decoded. Professional verification required.
|
||
</div>
|
||
|
||
<!-- Active Meeting Banner (hidden by default) -->
|
||
<div id="tscmMeetingBanner" class="tscm-meeting-banner" style="display: none;">
|
||
<div class="meeting-indicator">
|
||
<span class="meeting-pulse"></span>
|
||
<span class="meeting-text">MEETING WINDOW ACTIVE</span>
|
||
</div>
|
||
<div class="meeting-info">
|
||
<span id="tscmMeetingBannerName"></span>
|
||
<span id="tscmMeetingBannerTime"></span>
|
||
</div>
|
||
<div class="meeting-actions">
|
||
<button class="preset-btn" onclick="tscmShowMeetingSummary()" style="font-size: 10px; padding: 6px 8px;">
|
||
Summary
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capabilities Summary Bar -->
|
||
<div id="tscmCapabilitiesBar" class="tscm-capabilities-bar" style="display: none;">
|
||
<div class="cap-item" id="capWifi" title="WiFi Capability">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||
<span class="cap-status" id="capWifiStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capBt" title="Bluetooth Capability">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||
<span class="cap-status" id="capBtStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capRf" title="RF/SDR Capability">
|
||
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>
|
||
<span class="cap-status" id="capRfStatus">--</span>
|
||
</div>
|
||
<div class="cap-item" id="capRoot" title="Privilege Level">
|
||
<span class="cap-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="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||
<span class="cap-status" id="capRootStatus">--</span>
|
||
</div>
|
||
<div class="cap-limitations" id="capLimitations" onclick="tscmShowCapabilities()"
|
||
style="cursor: pointer;">
|
||
<span class="cap-warn icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||
<span id="capLimitationCount">0</span> limitations
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Baseline Health Indicator -->
|
||
<div id="tscmBaselineHealth" class="tscm-baseline-health" style="display: none;">
|
||
<span class="health-label">Baseline:</span>
|
||
<span class="health-name" id="baselineHealthName">--</span>
|
||
<span class="health-badge" id="baselineHealthBadge">--</span>
|
||
<span class="health-age" id="baselineHealthAge"></span>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="tscm-filter-bar">
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterProtocol">Protocol</label>
|
||
<select id="tscmFilterProtocol">
|
||
<option value="all">All</option>
|
||
<option value="wifi">WiFi</option>
|
||
<option value="bluetooth">Bluetooth</option>
|
||
<option value="rf">RF</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterRisk">Risk</label>
|
||
<select id="tscmFilterRisk">
|
||
<option value="all">All</option>
|
||
<option value="high_interest">High Interest</option>
|
||
<option value="review">Needs Review</option>
|
||
<option value="informational">Informational</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterStatus">Status</label>
|
||
<select id="tscmFilterStatus">
|
||
<option value="all">All</option>
|
||
<option value="new">New</option>
|
||
<option value="baseline">Baseline</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterKnown">Known</label>
|
||
<select id="tscmFilterKnown">
|
||
<option value="all">All</option>
|
||
<option value="known">Known</option>
|
||
<option value="unknown">Unknown</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-status" id="tscmFilterStatusText">Filters: none</div>
|
||
</div>
|
||
|
||
<!-- Risk Summary Banner (new scoring model) - clickable cards -->
|
||
<div class="tscm-threat-banner">
|
||
<div class="threat-card critical clickable" id="tscmHighInterestCard"
|
||
onclick="showDevicesByCategory('high_interest')"
|
||
title="Click to view high interest devices">
|
||
<span class="count" id="tscmHighInterestCount">0</span>
|
||
<span class="label">High Interest</span>
|
||
</div>
|
||
<div class="threat-card high clickable" id="tscmNeedsReviewCard"
|
||
onclick="showDevicesByCategory('review')" title="Click to view devices needing review">
|
||
<span class="count" id="tscmNeedsReviewCount">0</span>
|
||
<span class="label">Needs Review</span>
|
||
</div>
|
||
<div class="threat-card low clickable" id="tscmInformationalCard"
|
||
onclick="showDevicesByCategory('informational')"
|
||
title="Click to view informational devices">
|
||
<span class="count" id="tscmInformationalCount">0</span>
|
||
<span class="label">Informational</span>
|
||
</div>
|
||
<div class="threat-card medium clickable" id="tscmCorrelationsCard"
|
||
onclick="showDevicesByCategory('correlations')" title="Click to view correlations">
|
||
<span class="count" id="tscmCorrelationsCount">0</span>
|
||
<span class="label">Correlations</span>
|
||
</div>
|
||
<div class="threat-card medium clickable" id="tscmIdentityCard"
|
||
onclick="showDevicesByCategory('identity')" title="Click to view identity clusters">
|
||
<span class="count" id="tscmIdentityCount">0</span>
|
||
<span class="label">Identity Clusters</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sweep Summary (shown after sweep completes) -->
|
||
<div id="tscmSweepSummary" style="display: none; margin-bottom: 16px;"></div>
|
||
|
||
<!-- Signal Activity Timeline -->
|
||
<div id="tscmTimelineContainer" style="margin-bottom: 16px;"></div>
|
||
|
||
<!-- Cross-Protocol Correlations (shown when correlations found) -->
|
||
<div id="tscmCorrelationsContainer" style="display: none;"></div>
|
||
|
||
<!-- Main Content Grid -->
|
||
<div class="tscm-main-grid">
|
||
<!-- WiFi Panel -->
|
||
<div class="tscm-panel" id="tscmWifiPanel">
|
||
<div class="tscm-panel-header">
|
||
WiFi Networks
|
||
<span class="badge" id="tscmWifiCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmWifiList">
|
||
<div class="tscm-empty">Start a sweep to scan for WiFi networks</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Clients Panel -->
|
||
<div class="tscm-panel" id="tscmWifiClientPanel">
|
||
<div class="tscm-panel-header">
|
||
WiFi Clients
|
||
<span class="badge" id="tscmWifiClientCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmWifiClientList">
|
||
<div class="tscm-empty">Start a sweep to scan for WiFi clients</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Panel -->
|
||
<div class="tscm-panel" id="tscmBtPanel">
|
||
<div class="tscm-panel-header">
|
||
Bluetooth Devices
|
||
<span class="badge" id="tscmBtCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmBtList">
|
||
<div class="tscm-empty">Start a sweep to scan for Bluetooth devices</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RF Signals Panel -->
|
||
<div class="tscm-panel" id="tscmRfPanel">
|
||
<div class="tscm-panel-header">
|
||
RF Signals
|
||
<span class="badge" id="tscmRfCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmRfList">
|
||
<div class="tscm-empty">Enable RF scanning with an SDR device</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Threats Panel -->
|
||
<div class="tscm-panel" id="tscmThreatPanel">
|
||
<div class="tscm-panel-header">
|
||
Detected Threats
|
||
<span class="badge" id="tscmThreatCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmThreatList">
|
||
<div class="tscm-empty">
|
||
<div class="tscm-empty-primary">No anomalies detected</div>
|
||
<div class="tscm-empty-secondary">Start a sweep to scan for signals of interest</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Device Timelines Overview -->
|
||
<div class="tscm-panel" id="tscmDeviceTimelinesPanel" style="margin-top: 12px;">
|
||
<div class="tscm-panel-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
Device Timelines
|
||
<button class="preset-btn" onclick="loadDeviceTimelines()" style="font-size: 9px; padding: 3px 8px;">Refresh</button>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmDeviceTimelinesList">
|
||
<div class="tscm-empty">Run a sweep to see device timelines</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SubGHz Transceiver Dashboard -->
|
||
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
|
||
|
||
<!-- Stats Strip -->
|
||
<div class="subghz-stats-strip">
|
||
<div class="subghz-strip-group">
|
||
<span class="subghz-strip-device-badge" id="subghzStripDevice">
|
||
<span class="subghz-strip-device-dot" id="subghzStripDeviceDot"></span>
|
||
HackRF
|
||
</span>
|
||
<div class="subghz-strip-status">
|
||
<span class="subghz-strip-dot" id="subghzStripDot"></span>
|
||
<span class="subghz-strip-status-text" id="subghzStripStatus">Idle</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-cyan" id="subghzStripFreq">--</span>
|
||
<span class="subghz-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value" id="subghzStripMode">--</span>
|
||
<span class="subghz-strip-label">MODE</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-green" id="subghzStripSignals">0</span>
|
||
<span class="subghz-strip-label">SIGNALS</span>
|
||
</div>
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-orange" id="subghzStripCaptures">0</span>
|
||
<span class="subghz-strip-label">CAPTURES</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<span class="subghz-strip-timer" id="subghzStripTimer"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Console (collapsible) -->
|
||
<div class="subghz-signal-console" id="subghzConsole" style="display: none;">
|
||
<div class="subghz-console-header" onclick="SubGhz.toggleConsole()">
|
||
<div class="subghz-phase-strip">
|
||
<span class="subghz-phase-step" id="subghzPhaseTuning">TUNING</span>
|
||
<span class="subghz-phase-arrow">▸</span>
|
||
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
|
||
<span class="subghz-phase-arrow">▸</span>
|
||
<span class="subghz-phase-step" id="subghzPhaseDecoding">DECODING</span>
|
||
</div>
|
||
<div class="subghz-burst-indicator" id="subghzBurstIndicator" title="Live burst detector">
|
||
<span class="subghz-burst-dot"></span>
|
||
<span class="subghz-burst-text" id="subghzBurstText">NO BURST</span>
|
||
</div>
|
||
<button class="subghz-console-toggle" id="subghzConsoleToggleBtn">▼</button>
|
||
</div>
|
||
<div class="subghz-console-body" id="subghzConsoleBody">
|
||
<div class="subghz-console-log" id="subghzConsoleLog"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Hub (idle state — 2x2 Flipper-style cards) -->
|
||
<div class="subghz-action-hub" id="subghzActionHub">
|
||
<div class="subghz-hub-header">
|
||
<div class="subghz-hub-header-title">HackRF One</div>
|
||
<div class="subghz-hub-header-sub">SubGHz Transceiver — 1 MHz - 6 GHz</div>
|
||
</div>
|
||
<div class="subghz-hub-grid">
|
||
<div class="subghz-hub-card subghz-hub-card--green" onclick="SubGhz.hubAction('rx')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M1 12h4m14 0h4"/><path d="M5.6 5.6l2.85 2.85m7.1 7.1l2.85 2.85M5.6 18.4l2.85-2.85m7.1-7.1l2.85-2.85"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Read RAW</div>
|
||
<div class="subghz-hub-desc">Capture raw IQ via hackrf_transfer</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--red" onclick="SubGhz.hubAction('txselect')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M5 19h14"/><path d="M12 5v11"/><path d="M8 9l4-4 4 4"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Transmit</div>
|
||
<div class="subghz-hub-desc">Replay a saved capture</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--orange" onclick="SubGhz.hubAction('sweep')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M3 20h18"/><path d="M3 17l3-7 3 4 3-9 3 6 3-3 3 9"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Freq Analyzer</div>
|
||
<div class="subghz-hub-desc">Wideband sweep via hackrf_sweep</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--purple" onclick="SubGhz.hubAction('saved')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Saved</div>
|
||
<div class="subghz-hub-desc">Signal library & replay</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operation Panels (one visible at a time, replaces hub) -->
|
||
|
||
<!-- RX (Raw Capture) Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelRx" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Read RAW — Signal Capture</span>
|
||
<div class="subghz-op-panel-actions">
|
||
<button class="subghz-btn start" id="subghzRxStartBtnPanel" onclick="SubGhz.startRx()">Start</button>
|
||
<button class="subghz-btn stop" id="subghzRxStopBtnPanel" onclick="SubGhz.stopRx()" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-display">
|
||
<div class="subghz-rx-recording" id="subghzRxRecording" style="display: none;">
|
||
<span class="subghz-rx-rec-dot"></span>
|
||
<span>RECORDING</span>
|
||
</div>
|
||
<div class="subghz-rx-info-grid">
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">FREQUENCY</span>
|
||
<span class="subghz-rx-info-value accent-cyan" id="subghzRxFreq">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">LNA GAIN</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxLna">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">VGA GAIN</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxVga">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">SAMPLE RATE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxSampleRate">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">FILE SIZE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxFileSize">0 KB</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">DATA RATE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxRate">0 KB/s</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-level-wrapper">
|
||
<span class="subghz-rx-level-label">SIGNAL</span>
|
||
<span class="subghz-rx-burst-pill" id="subghzRxBurstPill">IDLE</span>
|
||
<div class="subghz-rx-level-bar">
|
||
<div class="subghz-rx-level-fill" id="subghzRxLevel" style="width: 0%;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-hint" id="subghzRxHint">
|
||
<span class="subghz-rx-hint-label">ANALYSIS</span>
|
||
<span class="subghz-rx-hint-text" id="subghzRxHintText">No modulation hint yet</span>
|
||
<span class="subghz-rx-hint-confidence" id="subghzRxHintConfidence">--</span>
|
||
</div>
|
||
<div class="subghz-rx-scope-wrap">
|
||
<span class="subghz-rx-scope-label">WAVEFORM</span>
|
||
<div class="subghz-rx-scope">
|
||
<canvas id="subghzRxScope"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-scope-wrap">
|
||
<div class="subghz-rx-waterfall-header">
|
||
<span class="subghz-rx-scope-label">WATERFALL</span>
|
||
<div class="subghz-rx-waterfall-controls">
|
||
<div class="subghz-wf-control">
|
||
<span>FLOOR</span>
|
||
<input type="range" id="subghzWfFloor" min="0" max="200" value="20" oninput="SubGhz.setWaterfallFloor(this.value)">
|
||
<span class="subghz-wf-value" id="subghzWfFloorVal">20</span>
|
||
</div>
|
||
<div class="subghz-wf-control">
|
||
<span>RANGE</span>
|
||
<input type="range" id="subghzWfRange" min="16" max="255" value="180" oninput="SubGhz.setWaterfallRange(this.value)">
|
||
<span class="subghz-wf-value" id="subghzWfRangeVal">180</span>
|
||
</div>
|
||
<button class="subghz-wf-pause-btn" id="subghzWfPauseBtn" onclick="SubGhz.toggleWaterfall()">PAUSE</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-waterfall">
|
||
<canvas id="subghzRxWaterfall"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sweep Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelSweep" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Frequency Analyzer</span>
|
||
<div class="subghz-op-panel-actions">
|
||
<button class="subghz-btn start" id="subghzSweepStartBtnPanel" onclick="SubGhz.startSweep()">Start</button>
|
||
<button class="subghz-btn stop" id="subghzSweepStopBtnPanel" onclick="SubGhz.stopSweep()" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-sweep-layout">
|
||
<div class="subghz-sweep-chart-wrapper" id="subghzSweepChartWrapper">
|
||
<canvas id="subghzSweepCanvas"></canvas>
|
||
</div>
|
||
<div class="subghz-sweep-peaks-sidebar" id="subghzSweepPeaksSidebar">
|
||
<div class="subghz-sweep-peaks-title">PEAKS</div>
|
||
<div class="subghz-peak-list" id="subghzSweepPeakList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TX Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelTx" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Transmit</span>
|
||
</div>
|
||
<div class="subghz-tx-display" id="subghzTxDisplay">
|
||
<div class="subghz-tx-pulse-ring">
|
||
<div class="subghz-tx-pulse-dot"></div>
|
||
</div>
|
||
<div class="subghz-tx-label" id="subghzTxStateLabel">READY</div>
|
||
<div class="subghz-tx-info-grid">
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">FREQUENCY</span>
|
||
<span class="subghz-tx-info-value accent-red" id="subghzTxFreqDisplay">--</span>
|
||
</div>
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">TX GAIN</span>
|
||
<span class="subghz-tx-info-value" id="subghzTxGainDisplay">--</span>
|
||
</div>
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">ELAPSED</span>
|
||
<span class="subghz-tx-info-value" id="subghzTxElapsed">0s</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-btn-row" style="max-width: 420px; margin: 16px auto 0;">
|
||
<button class="subghz-btn" id="subghzTxChooseCaptureBtn" onclick="SubGhz.showPanel('saved')">Choose Capture</button>
|
||
<button class="subghz-btn stop" id="subghzTxStopBtn" onclick="SubGhz.stopTx()">Stop Transmission</button>
|
||
<button class="subghz-btn start" id="subghzTxReplayLastBtn" onclick="SubGhz.replayLastTx()" style="display: none;">Replay Last</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Saved Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelSaved" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Saved Captures</span>
|
||
<div class="subghz-op-panel-actions subghz-saved-actions">
|
||
<span class="subghz-saved-selection-count" id="subghzSavedSelectionCount" style="display: none;">0 selected</span>
|
||
<button class="subghz-btn" id="subghzSavedSelectBtn" onclick="SubGhz.toggleCaptureSelectMode()">Select</button>
|
||
<button class="subghz-btn" id="subghzSavedSelectAllBtn" onclick="SubGhz.selectAllCaptures()" style="display: none;">Select All</button>
|
||
<button class="subghz-btn stop" id="subghzSavedDeleteSelectedBtn" onclick="SubGhz.deleteSelectedCaptures()" style="display: none;" disabled>Delete Selected</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-captures-list subghz-captures-list-main" id="subghzCapturesList" style="flex: 1; min-height: 0; max-height: none; overflow-y: auto;">
|
||
<div class="subghz-empty" id="subghzCapturesEmpty">No captures yet</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TX Confirmation Modal -->
|
||
<div id="subghzTxModalOverlay" class="subghz-tx-modal-overlay">
|
||
<div class="subghz-tx-modal">
|
||
<h3>Confirm Transmission</h3>
|
||
<p>You are about to transmit a radio signal on:</p>
|
||
<p class="tx-freq" id="subghzTxModalFreq">--- MHz</p>
|
||
<p class="tx-duration">Capture duration: <span id="subghzTxModalDuration">--</span></p>
|
||
<div class="subghz-tx-segment-box">
|
||
<label class="subghz-tx-segment-toggle">
|
||
<input type="checkbox" id="subghzTxSegmentEnabled" onchange="SubGhz.syncTxSegmentSelection()">
|
||
Transmit selected segment only
|
||
</label>
|
||
<div class="subghz-tx-segment-grid">
|
||
<label>Start (s)</label>
|
||
<input type="number" id="subghzTxSegmentStart" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('start')">
|
||
<label>End (s)</label>
|
||
<input type="number" id="subghzTxSegmentEnd" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('end')">
|
||
</div>
|
||
<p class="subghz-tx-segment-summary" id="subghzTxSegmentSummary">Full capture</p>
|
||
</div>
|
||
<div class="subghz-tx-burst-assist" id="subghzTxBurstAssist" style="display: none;">
|
||
<div class="subghz-tx-burst-title">Detected Bursts</div>
|
||
<div class="subghz-tx-burst-timeline" id="subghzTxBurstTimeline"></div>
|
||
<div class="subghz-tx-burst-range" id="subghzTxBurstRange">Drag on timeline to select TX segment</div>
|
||
<div class="subghz-tx-burst-list" id="subghzTxBurstList"></div>
|
||
</div>
|
||
<p>Ensure you have proper authorization to transmit on this frequency.</p>
|
||
<div class="subghz-tx-modal-actions">
|
||
<button class="subghz-tx-cancel-btn" onclick="SubGhz.cancelTx()">Cancel</button>
|
||
<button class="subghz-tx-trim-btn" id="subghzTxTrimBtn" onclick="SubGhz.trimCaptureSelection()">Trim + Save</button>
|
||
<button class="subghz-tx-confirm-btn" onclick="SubGhz.confirmTx()">Transmit</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- BT Locate SAR Dashboard -->
|
||
<div id="btLocateVisuals" class="btl-visuals-container" style="display: none; flex-direction: column; gap: 8px; flex: 1; min-height: 0; overflow: hidden; padding: 8px;">
|
||
<!-- Proximity HUD -->
|
||
<div class="btl-hud" id="btLocateHud" style="display: none;">
|
||
<div class="btl-hud-top">
|
||
<div class="btl-hud-band" id="btLocateBand">---</div>
|
||
<div class="btl-hud-metrics">
|
||
<div class="btl-hud-metric btl-hud-metric-lg">
|
||
<span class="btl-hud-value" id="btLocateDistance">--</span>
|
||
<span class="btl-hud-unit">m</span>
|
||
<span class="btl-hud-label">Est. Distance</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateRssi">--</span>
|
||
<span class="btl-hud-unit">dBm</span>
|
||
<span class="btl-hud-label">RSSI</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateRssiEma">--</span>
|
||
<span class="btl-hud-unit">dBm</span>
|
||
<span class="btl-hud-label">RSSI avg</span>
|
||
</div>
|
||
<div class="btl-hud-separator"></div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateDetectionCount">0</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">Detections</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateGpsCount">0</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">GPS pts</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateSessionTime">0:00</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">Duration</span>
|
||
</div>
|
||
</div>
|
||
<div class="btl-hud-controls">
|
||
<label class="btl-hud-audio-toggle">
|
||
<input type="checkbox" id="btLocateAudioEnable" onchange="BtLocate.toggleAudio()">
|
||
<span>Audio</span>
|
||
</label>
|
||
<div class="btl-hud-export-row">
|
||
<select id="btLocateExportFormat" class="btl-hud-export-format">
|
||
<option value="csv">CSV</option>
|
||
<option value="gpx">GPX</option>
|
||
<option value="kml">KML</option>
|
||
</select>
|
||
<button class="btl-hud-clear-btn" onclick="BtLocate.exportTrail()">Export</button>
|
||
</div>
|
||
<button class="btl-hud-clear-btn" onclick="BtLocate.clearTrail()">Clear Trail</button>
|
||
</div>
|
||
</div>
|
||
<div class="btl-hud-bottom">
|
||
<div class="btl-hud-info">
|
||
<span class="btl-hud-info-item" id="btLocateTargetInfo">--</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateEnvInfo">--</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateConfidenceInfo">Confidence: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateBestSignal">Best: --</span>
|
||
</div>
|
||
<div id="btLocateDiag" class="btl-hud-diag"></div>
|
||
</div>
|
||
</div>
|
||
<div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;">
|
||
<div id="btLocateMap" style="position: absolute; inset: 0;"></div>
|
||
<div id="btLocateCrosshairOverlay" class="btl-crosshair-overlay" aria-hidden="true">
|
||
<div class="btl-crosshair-line btl-crosshair-vertical"></div>
|
||
<div class="btl-crosshair-line btl-crosshair-horizontal"></div>
|
||
</div>
|
||
<div class="btl-map-overlay-controls">
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
|
||
<span>Heatmap</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateMovementEnable" onchange="BtLocate.toggleMovement()">
|
||
<span>Movement</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateFollowEnable" onchange="BtLocate.toggleFollow()">
|
||
<span>Auto follow</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateSmoothEnable" onchange="BtLocate.toggleSmoothing()">
|
||
<span>Smooth path</span>
|
||
</label>
|
||
</div>
|
||
<div class="btl-map-heat-legend" id="btLocateHeatLegend">
|
||
<span class="btl-map-heat-label">Signal Heat</span>
|
||
<div class="btl-map-heat-bar"></div>
|
||
<div class="btl-map-heat-scale">
|
||
<span>Weak</span>
|
||
<span>Strong</span>
|
||
</div>
|
||
</div>
|
||
<div class="btl-map-track-stats" id="btLocateTrackStats">Track: 0 m | 0 pts</div>
|
||
</div>
|
||
<div class="btl-rssi-chart-container">
|
||
<span class="btl-chart-label">RSSI History</span>
|
||
<canvas id="btLocateRssiChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi Locate Dashboard -->
|
||
<div id="wflVisuals" class="wfl-visuals-container" style="display: none;">
|
||
<div class="wfl-hud" id="wflHud" style="display: none;">
|
||
<div class="wfl-hud-header">
|
||
<div class="wfl-hud-target">
|
||
<span class="wfl-target-ssid" id="wflTargetSsid">--</span>
|
||
<span class="wfl-target-bssid" id="wflTargetBssid">--</span>
|
||
</div>
|
||
<label class="wfl-hud-audio-toggle">
|
||
<input type="checkbox" id="wflAudioEnable" onchange="WiFiLocate.toggleAudio()"> Audio
|
||
</label>
|
||
<button class="wfl-hud-stop-btn" onclick="WiFiLocate.stop()">Stop Tracking</button>
|
||
</div>
|
||
<div class="wfl-rssi-display" id="wflRssiValue">--</div>
|
||
<div class="wfl-distance" id="wflDistance">--</div>
|
||
<div class="wfl-bar-container" id="wflBarContainer"></div>
|
||
<div class="wfl-rssi-chart-container">
|
||
<span class="wfl-chart-label">RSSI History</span>
|
||
<canvas id="wflRssiChart"></canvas>
|
||
</div>
|
||
<div class="wfl-stats">
|
||
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatCurrent">--</span><span class="wfl-stat-label">Current</span></div>
|
||
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatMin">--</span><span class="wfl-stat-label">Min</span></div>
|
||
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatMax">--</span><span class="wfl-stat-label">Max</span></div>
|
||
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatAvg">--</span><span class="wfl-stat-label">Avg</span></div>
|
||
</div>
|
||
<div class="wfl-signal-lost" id="wflSignalLost" style="display: none;">SIGNAL LOST</div>
|
||
</div>
|
||
<div class="wfl-waiting" id="wflWaiting">
|
||
<p>Enter a target BSSID and click Start Locate</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WebSDR Dashboard -->
|
||
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||
<!-- Audio Control Bar (hidden until connected) -->
|
||
<div id="kiwiAudioControls" class="radio-module-box" style="display: none; padding: 8px 12px; flex-shrink: 0;">
|
||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||
<!-- Live indicator -->
|
||
<div style="display: flex; align-items: center; gap: 5px;">
|
||
<div id="kiwiLiveIndicator" style="width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green); animation: pulse 1.5s infinite;"></div>
|
||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">LIVE</span>
|
||
</div>
|
||
<!-- Receiver name -->
|
||
<span id="kiwiBarReceiverName" style="font-size: 11px; color: var(--accent-cyan); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
|
||
<!-- Frequency input -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<input type="number" id="kiwiBarFrequency" step="1" style="width: 80px; font-size: 12px; font-family: var(--font-mono); padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||
<span style="font-size: 10px; color: var(--text-muted);">kHz</span>
|
||
</div>
|
||
<!-- Mode selector -->
|
||
<select id="kiwiBarMode" style="font-size: 11px; padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB</option>
|
||
<option value="lsb">LSB</option>
|
||
<option value="cw">CW</option>
|
||
</select>
|
||
<!-- Tune button -->
|
||
<button class="preset-btn" onclick="tuneFromBar()" style="font-size: 10px; padding: 3px 10px;">Tune</button>
|
||
<!-- Volume -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
|
||
<input type="range" id="kiwiBarVolume" min="0" max="100" value="80" style="width: 60px;" oninput="setKiwiVolume(this.value)">
|
||
</div>
|
||
<!-- S-meter mini -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<div style="width: 50px; height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
|
||
<div id="kiwiBarSmeter" style="height: 100%; width: 0%; background: linear-gradient(to right, var(--accent-green), var(--accent-orange)); transition: width 0.2s; border-radius: 3px;"></div>
|
||
</div>
|
||
<span id="kiwiBarSmeterValue" style="font-size: 9px; color: var(--text-muted); font-family: var(--font-mono); min-width: 20px;">S0</span>
|
||
</div>
|
||
<!-- Disconnect -->
|
||
<button class="stop-btn" onclick="disconnectFromReceiver()" style="font-size: 10px; padding: 3px 10px; margin-left: auto;">Disconnect</button>
|
||
</div>
|
||
</div>
|
||
<!-- Map and receiver list side by side -->
|
||
<div style="display: grid; grid-template-columns: 3fr 1fr; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||
<!-- Map -->
|
||
<div class="radio-module-box" style="padding: 0; overflow: hidden; position: relative; min-height: 0;">
|
||
<div id="websdrMap" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;"></div>
|
||
</div>
|
||
<!-- Receiver List -->
|
||
<div style="display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; overflow: hidden;">
|
||
<div class="radio-module-box" style="padding: 10px; flex: 1; overflow-y: auto; min-height: 0;">
|
||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||
<span>RECEIVERS</span>
|
||
<span id="websdrReceiverCount" style="color: var(--accent-cyan);">0 found</span>
|
||
</div>
|
||
<div id="websdrReceiverList" style="font-size: 11px;">
|
||
<div style="color: var(--text-muted); text-align: center; padding: 20px;">Click "Find Receivers" to search</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- Meshtastic Messages Dashboard -->
|
||
<div id="meshtasticVisuals" class="mesh-visuals-container" style="display: none;">
|
||
<!-- Compact Status Strip -->
|
||
<div class="mesh-stats-strip">
|
||
<div class="mesh-strip-group">
|
||
<button class="mesh-strip-sidebar-toggle" onclick="Meshtastic.toggleSidebar()" title="Toggle sidebar">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/>
|
||
</svg>
|
||
</button>
|
||
<div class="mesh-strip-status">
|
||
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
|
||
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
|
||
</div>
|
||
<select id="meshStripConnType" class="mesh-strip-select" title="Connection Type" onchange="Meshtastic.onConnectionTypeChange()" style="width: 70px;">
|
||
<option value="serial">Serial</option>
|
||
<option value="tcp">TCP</option>
|
||
</select>
|
||
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
|
||
<option value="">Auto-detect</option>
|
||
</select>
|
||
<input type="text" id="meshStripHostname" class="mesh-strip-input" placeholder="IP address" title="Hostname/IP for TCP" style="display: none; width: 120px;">
|
||
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
|
||
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
|
||
</div>
|
||
<div class="mesh-strip-divider"></div>
|
||
<div class="mesh-strip-group">
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value" id="meshStripNodeName">--</span>
|
||
<span class="mesh-strip-label">NODE</span>
|
||
</div>
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value mesh-strip-id" id="meshStripNodeId">--</span>
|
||
<span class="mesh-strip-label">ID</span>
|
||
</div>
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value" id="meshStripModel">--</span>
|
||
<span class="mesh-strip-label">MODEL</span>
|
||
</div>
|
||
</div>
|
||
<div class="mesh-strip-divider"></div>
|
||
<div class="mesh-strip-group">
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value accent-cyan" id="meshStripMsgCount">0</span>
|
||
<span class="mesh-strip-label">MSGS</span>
|
||
</div>
|
||
<div class="mesh-strip-stat">
|
||
<span class="mesh-strip-value accent-green" id="meshStripNodeCount">0</span>
|
||
<span class="mesh-strip-label">NODES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Row (Messages + Map side by side) -->
|
||
<div class="mesh-main-row">
|
||
<!-- Messages Section -->
|
||
<div class="mesh-messages-section">
|
||
<div class="mesh-messages-header">
|
||
<div class="mesh-messages-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||
</svg>
|
||
Messages
|
||
</div>
|
||
<div class="mesh-messages-filter">
|
||
<select id="meshVisualsFilter" onchange="Meshtastic.applyFilter()">
|
||
<option value="">All Channels</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mesh-messages-list" id="meshMessagesGrid">
|
||
<div class="mesh-messages-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||
</svg>
|
||
<p>Connect to a Meshtastic device to see messages</p>
|
||
</div>
|
||
</div>
|
||
<!-- Message Compose Box -->
|
||
<div class="mesh-compose" id="meshCompose" style="display: none;">
|
||
<div class="mesh-compose-header">
|
||
<select id="meshComposeChannel" class="mesh-compose-channel" title="Channel to send on">
|
||
<option value="0">CH 0</option>
|
||
</select>
|
||
<input type="text" id="meshComposeTo" placeholder="^all (broadcast)" class="mesh-compose-to" title="Destination node ID or ^all for broadcast">
|
||
</div>
|
||
<div class="mesh-compose-body">
|
||
<input type="text" id="meshComposeText" placeholder="Type a message..." maxlength="237" class="mesh-compose-input" oninput="Meshtastic.updateCharCount()" onkeydown="Meshtastic.handleComposeKeydown(event)">
|
||
<button onclick="Meshtastic.sendMessage()" class="mesh-compose-send" title="Send message">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="mesh-compose-hint">
|
||
<span id="meshComposeCount">0</span>/237
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Node Map -->
|
||
<div class="mesh-map-section">
|
||
<div class="mesh-map-header">
|
||
<div class="mesh-map-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||
</svg>
|
||
Node Map
|
||
</div>
|
||
<div class="mesh-map-stats">
|
||
<span>NODES: <span id="meshMapNodeCount">0</span></span>
|
||
<span>WITH GPS: <span id="meshMapGpsCount">0</span></span>
|
||
</div>
|
||
</div>
|
||
<div id="meshMap" class="mesh-map"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSTV Decoder Dashboard -->
|
||
<div id="sstvVisuals" class="sstv-visuals-container" style="display: none;">
|
||
<!-- Status Strip -->
|
||
<div class="sstv-stats-strip">
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-status">
|
||
<span class="sstv-strip-dot idle" id="sstvStripDot"></span>
|
||
<span class="sstv-strip-status-text" id="sstvStripStatus">Idle</span>
|
||
</div>
|
||
<button class="sstv-strip-btn start" id="sstvStartBtn" onclick="SSTV.start()">Start</button>
|
||
<button class="sstv-strip-btn stop" id="sstvStopBtn" onclick="SSTV.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-stat">
|
||
<span class="sstv-strip-value accent-cyan">145.800</span>
|
||
<span class="sstv-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="sstv-strip-stat">
|
||
<span class="sstv-strip-value" id="sstvStripImageCount">0</span>
|
||
<span class="sstv-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<!-- Location Controls -->
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-location">
|
||
<span class="sstv-strip-label" style="margin-right: 6px;">LOC</span>
|
||
<input type="number" id="sstvObsLat" class="sstv-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||
<input type="number" id="sstvObsLon" class="sstv-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||
<button class="sstv-strip-btn gps" onclick="SSTV.useGPS(this)" title="Use GPS location">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>
|
||
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
|
||
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
|
||
</svg>
|
||
GPS
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<!-- TLE Update -->
|
||
<div class="sstv-strip-group">
|
||
<button class="sstv-strip-btn update-tle" onclick="SSTV.updateTLE(this)" title="Update satellite orbital data from CelesTrak">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||
<path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||
<path d="M16 21h5v-5"/>
|
||
</svg>
|
||
Update TLE
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ISS Tracking Map + Countdown Row -->
|
||
<div class="sstv-top-row">
|
||
<!-- ISS Map -->
|
||
<div class="sstv-map-row">
|
||
<div class="sstv-map-container">
|
||
<div id="sstvIssMap" class="sstv-iss-map"></div>
|
||
<div class="sstv-map-overlay">
|
||
<div class="sstv-map-info">
|
||
<span class="sstv-map-label">ISS</span>
|
||
<span class="sstv-map-coords"><span id="sstvIssLat">--.-</span>°, <span id="sstvIssLon">--.-</span>°</span>
|
||
<span class="sstv-map-alt">Alt: <span id="sstvIssAlt">---</span> km</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Next Pass Countdown Panel -->
|
||
<div class="sstv-countdown-panel">
|
||
<div class="sstv-countdown-header">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<polyline points="12 6 12 12 16 14"/>
|
||
</svg>
|
||
<span>Next Pass</span>
|
||
</div>
|
||
<div class="sstv-countdown-body">
|
||
<div class="sstv-countdown-timer" id="sstvCountdownTimer">
|
||
<span class="sstv-countdown-value" id="sstvCountdownValue">--:--:--</span>
|
||
<span class="sstv-countdown-label" id="sstvCountdownLabel">Set location</span>
|
||
</div>
|
||
<div class="sstv-countdown-details">
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Start</span>
|
||
<span class="sstv-detail-value" id="sstvPassStart">--:--</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Max El</span>
|
||
<span class="sstv-detail-value" id="sstvPassMaxEl">--°</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Duration</span>
|
||
<span class="sstv-detail-value" id="sstvPassDuration">-- min</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Direction</span>
|
||
<span class="sstv-detail-value" id="sstvPassDirection">--</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-countdown-status" id="sstvCountdownStatus">
|
||
<span class="sstv-status-dot"></span>
|
||
<span>Waiting for pass data...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Scope -->
|
||
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="sstvScopeToneLabel" style="color: #444;">QUIET</span>
|
||
<span id="sstvScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sstvScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Row (Live + Gallery) -->
|
||
<div class="sstv-main-row">
|
||
<!-- Live Decode Section -->
|
||
<div class="sstv-live-section">
|
||
<div class="sstv-live-header">
|
||
<div class="sstv-live-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
Live Decode
|
||
</div>
|
||
</div>
|
||
<div class="sstv-live-content" id="sstvLiveContent">
|
||
<div class="sstv-idle-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||
</svg>
|
||
<h4>ISS SSTV Decoder</h4>
|
||
<p>Click Start to listen for SSTV transmissions on 145.800 MHz</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gallery Section -->
|
||
<div class="sstv-gallery-section">
|
||
<div class="sstv-gallery-header">
|
||
<div class="sstv-gallery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Decoded Images
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:4px;">
|
||
<span class="sstv-gallery-count" id="sstvImageCount">0</span>
|
||
<button class="sstv-gallery-clear-btn" onclick="SSTV.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-gallery-grid" id="sstvGallery">
|
||
<div class="sstv-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
<p>No images decoded yet</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
|
||
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
|
||
<!-- Stats strip -->
|
||
<div class="wxsat-stats-strip">
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-status">
|
||
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
|
||
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
|
||
</div>
|
||
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
|
||
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
|
||
<span class="wxsat-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
|
||
<span class="wxsat-strip-label">MODE</span>
|
||
</div>
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
|
||
<span class="wxsat-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-location">
|
||
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
|
||
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
|
||
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
|
||
<span class="wxsat-toggle-label">AUTO</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Countdown + Timeline -->
|
||
<div class="wxsat-countdown-bar">
|
||
<div class="wxsat-countdown-next">
|
||
<div class="wxsat-countdown-boxes" id="wxsatCountdownBoxes">
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdDays">--</span><span class="wxsat-cd-unit">DAYS</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdHours">--</span><span class="wxsat-cd-unit">HRS</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdMins">--</span><span class="wxsat-cd-unit">MIN</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdSecs">--</span><span class="wxsat-cd-unit">SEC</span></div>
|
||
</div>
|
||
<div class="wxsat-countdown-info" id="wxsatCountdownInfo">
|
||
<span class="wxsat-countdown-sat" id="wxsatCountdownSat">--</span>
|
||
<span class="wxsat-countdown-detail" id="wxsatCountdownDetail">No passes predicted</span>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-timeline" id="wxsatTimeline">
|
||
<div class="wxsat-timeline-track" id="wxsatTimelineTrack"></div>
|
||
<div class="wxsat-timeline-cursor" id="wxsatTimelineCursor"></div>
|
||
<div class="wxsat-timeline-labels">
|
||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capture progress -->
|
||
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
||
<div class="wxsat-capture-info">
|
||
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
|
||
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
|
||
</div>
|
||
<div class="wxsat-progress-bar">
|
||
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Decoder Console -->
|
||
<div class="wxsat-signal-console" id="wxsatSignalConsole">
|
||
<div class="wxsat-console-header">
|
||
<div class="wxsat-console-title-group">
|
||
<span class="wxsat-console-title">DECODER CONSOLE</span>
|
||
<div class="wxsat-phase-indicator" id="wxsatPhaseIndicator">
|
||
<span class="wxsat-phase-step" data-phase="tuning">TUNING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="listening">LISTENING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="signal_detected">SIGNAL</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="decoding">DECODING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
|
||
</div>
|
||
</div>
|
||
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
|
||
onclick="WeatherSat.toggleConsole()" title="Toggle console">▼</button>
|
||
</div>
|
||
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
||
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
||
<div class="wxsat-console-entry wxsat-log-info">Waiting for capture...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main content: 3-column layout -->
|
||
<div class="wxsat-content">
|
||
<!-- Left: Pass predictions -->
|
||
<div class="wxsat-passes-panel">
|
||
<div class="wxsat-passes-header">
|
||
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
||
</div>
|
||
<div class="wxsat-passes-list" id="wxsatPassesList">
|
||
<div class="wxsat-gallery-empty">
|
||
<p>Set location to see pass predictions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Center: Polar plot + Ground track map -->
|
||
<div class="wxsat-center-panel">
|
||
<div class="wxsat-polar-container">
|
||
<div class="wxsat-panel-header">
|
||
<span class="wxsat-panel-title">Polar Plot</span>
|
||
<span class="wxsat-panel-subtitle" id="wxsatPolarSat">--</span>
|
||
</div>
|
||
<canvas id="wxsatPolarCanvas" width="300" height="300"></canvas>
|
||
</div>
|
||
<div class="wxsat-map-container">
|
||
<div class="wxsat-panel-header">
|
||
<span class="wxsat-panel-title">Global Projection</span>
|
||
<span class="wxsat-panel-subtitle" id="wxsatMapInfo">--</span>
|
||
</div>
|
||
<div id="wxsatGroundMap" class="wxsat-ground-map"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Image gallery -->
|
||
<div class="wxsat-gallery-panel">
|
||
<div class="wxsat-gallery-header">
|
||
<span class="wxsat-gallery-title">Decoded Images</span>
|
||
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
|
||
<button class="wxsat-gallery-clear-btn" onclick="WeatherSat.deleteAllImages()" title="Delete all images">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="wxsat-gallery-grid" id="wxsatGallery">
|
||
<div class="wxsat-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<path d="M2 12h20"/>
|
||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||
</svg>
|
||
<p>No images decoded yet</p>
|
||
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSTV General Decoder Dashboard -->
|
||
<div id="sstvGeneralVisuals" class="sstv-general-visuals-container" style="display: none;">
|
||
<!-- Status Strip -->
|
||
<div class="sstv-general-stats-strip">
|
||
<div class="sstv-general-strip-group">
|
||
<div class="sstv-general-strip-status">
|
||
<span class="sstv-general-strip-dot idle" id="sstvGeneralStripDot"></span>
|
||
<span class="sstv-general-strip-status-text" id="sstvGeneralStripStatus">Idle</span>
|
||
</div>
|
||
<button class="sstv-general-strip-btn start" id="sstvGeneralStartBtn" onclick="SSTVGeneral.start()">Start</button>
|
||
<button class="sstv-general-strip-btn stop" id="sstvGeneralStopBtn" onclick="SSTVGeneral.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="sstv-general-strip-divider"></div>
|
||
<div class="sstv-general-strip-group">
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value accent-cyan" id="sstvGeneralStripFreq">14.230</span>
|
||
<span class="sstv-general-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value" id="sstvGeneralStripMod">FM</span>
|
||
<span class="sstv-general-strip-label">MOD</span>
|
||
</div>
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value" id="sstvGeneralStripImageCount">0</span>
|
||
<span class="sstv-general-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Scope -->
|
||
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="sstvGeneralScopeToneLabel" style="color: #444;">QUIET</span>
|
||
<span id="sstvGeneralScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sstvGeneralScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Row (Live + Gallery) -->
|
||
<div class="sstv-general-main-row">
|
||
<!-- Live Decode Section -->
|
||
<div class="sstv-general-live-section">
|
||
<div class="sstv-general-live-header">
|
||
<div class="sstv-general-live-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
Live Decode
|
||
</div>
|
||
</div>
|
||
<div class="sstv-general-live-content" id="sstvGeneralLiveContent">
|
||
<div class="sstv-general-idle-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||
</svg>
|
||
<h4>SSTV Decoder</h4>
|
||
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gallery Section -->
|
||
<div class="sstv-general-gallery-section">
|
||
<div class="sstv-general-gallery-header">
|
||
<div class="sstv-general-gallery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Decoded Images
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:4px;">
|
||
<span class="sstv-general-gallery-count" id="sstvGeneralImageCount">0</span>
|
||
<button class="sstv-general-gallery-clear-btn" onclick="SSTVGeneral.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-general-gallery-grid" id="sstvGeneralGallery">
|
||
<div class="sstv-general-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
<p>No images decoded yet</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WeFax Decoder Dashboard -->
|
||
<div id="wefaxVisuals" class="wefax-visuals-container" style="display: none;">
|
||
<!-- Stats Strip -->
|
||
<div class="wefax-stats-strip">
|
||
<div class="wefax-strip-group">
|
||
<div class="wefax-strip-status">
|
||
<span class="wefax-strip-dot idle" id="wefaxStripDot"></span>
|
||
<span class="wefax-strip-status-text" id="wefaxStripStatus">Idle</span>
|
||
</div>
|
||
<button class="wefax-strip-btn start" id="wefaxStartBtn" onclick="WeFax.start()">Start</button>
|
||
<button class="wefax-strip-btn stop" id="wefaxStopBtn" onclick="WeFax.stop()" style="display: none;">Stop</button>
|
||
<label class="wefax-schedule-toggle" title="Auto-capture broadcasts">
|
||
<input type="checkbox" id="wefaxStripAutoSchedule"
|
||
onchange="WeFax.toggleScheduler(this)">
|
||
<span>Auto</span>
|
||
</label>
|
||
</div>
|
||
<div class="wefax-strip-divider"></div>
|
||
<div class="wefax-strip-group">
|
||
<div class="wefax-strip-stat">
|
||
<span class="wefax-strip-value accent-amber" id="wefaxStripFreq">---</span>
|
||
<span class="wefax-strip-label">KHZ</span>
|
||
</div>
|
||
<div class="wefax-strip-stat">
|
||
<span class="wefax-strip-value" id="wefaxStripLines">0</span>
|
||
<span class="wefax-strip-label">LINES</span>
|
||
</div>
|
||
<div class="wefax-strip-stat">
|
||
<span class="wefax-strip-value" id="wefaxStripImageCount">0</span>
|
||
<span class="wefax-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Countdown + Timeline -->
|
||
<div class="wefax-countdown-bar" id="wefaxCountdownBar" style="display: none;">
|
||
<div class="wefax-countdown-next">
|
||
<div class="wefax-countdown-boxes" id="wefaxCountdownBoxes">
|
||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdHours">--</span><span class="wefax-cd-unit">HRS</span></div>
|
||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdMins">--</span><span class="wefax-cd-unit">MIN</span></div>
|
||
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdSecs">--</span><span class="wefax-cd-unit">SEC</span></div>
|
||
</div>
|
||
<div class="wefax-countdown-info" id="wefaxCountdownInfo">
|
||
<span class="wefax-countdown-content" id="wefaxCountdownContent">--</span>
|
||
<span class="wefax-countdown-detail" id="wefaxCountdownDetail">Select a station</span>
|
||
</div>
|
||
</div>
|
||
<div class="wefax-timeline" id="wefaxTimeline">
|
||
<div class="wefax-timeline-track" id="wefaxTimelineTrack"></div>
|
||
<div class="wefax-timeline-cursor" id="wefaxTimelineCursor"></div>
|
||
<div class="wefax-timeline-labels">
|
||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Audio Waveform Scope -->
|
||
<div id="wefaxScopePanel" style="display: none;">
|
||
<div style="background: #0a0a0a; border: 1px solid #2e2a1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="wefaxScopeRmsLabel" style="color: #ffaa00; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="wefaxScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="wefaxScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="wefaxScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schedule Timeline -->
|
||
<div class="wefax-schedule-panel">
|
||
<div class="wefax-schedule-header">
|
||
<span class="wefax-schedule-title">Broadcast Schedule</span>
|
||
<span id="wefaxStatusText" style="font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></span>
|
||
</div>
|
||
<div id="wefaxScheduleTimeline">
|
||
<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content: Live Preview + Gallery -->
|
||
<div class="wefax-main-row">
|
||
<div class="wefax-live-section">
|
||
<div class="wefax-live-header">
|
||
<div class="wefax-live-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
</svg>
|
||
Live Decode
|
||
</div>
|
||
</div>
|
||
<div class="wefax-live-content" id="wefaxLiveContent">
|
||
<div class="wefax-idle-state" id="wefaxIdleState">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||
</svg>
|
||
<h4>WeFax Decoder</h4>
|
||
<p>Select a station and click Start to decode weather fax transmissions</p>
|
||
</div>
|
||
<img id="wefaxLivePreview" class="wefax-live-preview" style="display: none;" alt="WeFax decode in progress">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wefax-gallery-section">
|
||
<div class="wefax-gallery-header">
|
||
<div class="wefax-gallery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21 15 16 10 5 21"/>
|
||
</svg>
|
||
Decoded Images
|
||
</div>
|
||
<div class="wefax-gallery-controls">
|
||
<span class="wefax-gallery-count" id="wefaxImageCount">0</span>
|
||
<button class="wefax-gallery-clear-btn" onclick="WeFax.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="wefax-gallery-grid" id="wefaxGallery">
|
||
<div class="wefax-gallery-empty">No images decoded yet</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Space Weather Dashboard -->
|
||
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||
<!-- Header metrics strip -->
|
||
<div class="sw-header-strip">
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
|
||
<span class="sw-header-label">SFI</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripKp">--</span>
|
||
<span class="sw-header-label">Kp</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripA">--</span>
|
||
<span class="sw-header-label">A-Index</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripSsn">--</span>
|
||
<span class="sw-header-label">SSN</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripWind">--</span>
|
||
<span class="sw-header-label">Wind</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripBz">--</span>
|
||
<span class="sw-header-label">Bz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NOAA G/S/R Scales -->
|
||
<div class="sw-scales-row">
|
||
<div class="sw-scale-card" id="swScaleG">
|
||
<div class="sw-scale-label">Geomagnetic</div>
|
||
<div class="sw-scale-value sw-scale-0">G0</div>
|
||
<div class="sw-scale-desc">Quiet</div>
|
||
</div>
|
||
<div class="sw-scale-card" id="swScaleS">
|
||
<div class="sw-scale-label">Solar Radiation</div>
|
||
<div class="sw-scale-value sw-scale-0">S0</div>
|
||
<div class="sw-scale-desc">None</div>
|
||
</div>
|
||
<div class="sw-scale-card" id="swScaleR">
|
||
<div class="sw-scale-label">Radio Blackouts</div>
|
||
<div class="sw-scale-value sw-scale-0">R0</div>
|
||
<div class="sw-scale-desc">None</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HF Band Conditions -->
|
||
<div class="sw-band-panel">
|
||
<div class="sw-band-title">HF Band Conditions</div>
|
||
<div class="sw-band-grid" id="swBandGrid">
|
||
<div class="sw-loading">Loading band data</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts row -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Kp Index (3-hourly)</div>
|
||
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Solar Wind</div>
|
||
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
|
||
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Flare Probability</div>
|
||
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Solar imagery gallery -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">Solar Imagery (SDO)</div>
|
||
<div class="sw-image-tabs">
|
||
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193Å</button>
|
||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304Å</button>
|
||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_magnetogram" onclick="SpaceWeather.selectSolarImage('sdo_magnetogram')">Magnetogram</button>
|
||
</div>
|
||
<div class="sw-image-frame" id="swSolarImageFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">Aurora Forecast (North)</div>
|
||
<div class="sw-image-frame" id="swAuroraFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- D-RAP absorption map -->
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">D-Region Absorption (D-RAP)</div>
|
||
<div class="sw-drap-freqs">
|
||
<button class="sw-drap-freq-btn active" data-key="drap_global" onclick="SpaceWeather.selectDrapFreq('drap_global')">Global</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_5" onclick="SpaceWeather.selectDrapFreq('drap_5')">5 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_10" onclick="SpaceWeather.selectDrapFreq('drap_10')">10 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_15" onclick="SpaceWeather.selectDrapFreq('drap_15')">15 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_20" onclick="SpaceWeather.selectDrapFreq('drap_20')">20 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_25" onclick="SpaceWeather.selectDrapFreq('drap_25')">25 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_30" onclick="SpaceWeather.selectDrapFreq('drap_30')">30 MHz</button>
|
||
</div>
|
||
<div class="sw-image-frame" id="swDrapImageFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alerts & Active Regions -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-alerts-panel">
|
||
<div class="sw-chart-title">Active Alerts</div>
|
||
<div id="swAlertsList"><div class="sw-loading">Loading</div></div>
|
||
</div>
|
||
<div class="sw-regions-panel">
|
||
<div class="sw-chart-title">Active Sunspot Regions</div>
|
||
<table class="sw-regions-table">
|
||
<thead>
|
||
<tr><th>Region</th><th>Date</th><th>Loc</th><th>Lo</th><th>Area</th></tr>
|
||
</thead>
|
||
<tbody id="swRegionsBody">
|
||
<tr><td colspan="5" class="sw-loading">Loading</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Waterfall Visuals -->
|
||
<div id="waterfallVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
|
||
<div class="wf-container">
|
||
<div class="wf-headline">
|
||
<div class="wf-headline-left">
|
||
<span class="wf-headline-tag">SPECTRUM RECEIVER</span>
|
||
<span class="wf-headline-sub">Local SDR</span>
|
||
</div>
|
||
<div class="wf-headline-right">
|
||
<span class="wf-range-text" id="wfRangeDisplay">98.8000 - 101.2000 MHz</span>
|
||
<span class="wf-tune-text" id="wfTuneDisplay">Tune 100.0000 MHz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-monitor-strip">
|
||
<div class="wf-rx-vfo">
|
||
<div class="wf-rx-vfo-top">
|
||
<span class="wf-rx-vfo-name">VFO-A</span>
|
||
<span class="wf-rx-vfo-status" id="wfVisualStatus">IDLE</span>
|
||
</div>
|
||
<div class="wf-rx-vfo-readout">
|
||
<span id="wfRxFreqReadout">100.0000</span>
|
||
<span class="wf-rx-vfo-unit">MHz</span>
|
||
</div>
|
||
<div class="wf-rx-vfo-bottom">
|
||
<span id="wfRxModeReadout">WFM</span>
|
||
<span id="wfRxStepReadout">STEP 100 kHz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-modebank" id="wfModeBank">
|
||
<button class="wf-mode-btn is-active" data-mode="wfm">WFM</button>
|
||
<button class="wf-mode-btn" data-mode="fm">NFM</button>
|
||
<button class="wf-mode-btn" data-mode="am">AM</button>
|
||
<button class="wf-mode-btn" data-mode="usb">USB</button>
|
||
<button class="wf-mode-btn" data-mode="lsb">LSB</button>
|
||
<select id="wfMonitorMode" class="wf-monitor-select wf-monitor-select-hidden">
|
||
<option value="wfm" selected>WFM</option>
|
||
<option value="fm">NFM</option>
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB</option>
|
||
<option value="lsb">LSB</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="wf-rx-levels">
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Squelch</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorSquelch" min="0" max="100" value="0">
|
||
<span id="wfMonitorSquelchValue" class="wf-monitor-value">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Gain</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorGain" min="0" max="60" value="40">
|
||
<span id="wfMonitorGainValue" class="wf-monitor-value">40</span>
|
||
</div>
|
||
</div>
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Volume</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorVolume" min="0" max="100" value="82">
|
||
<span id="wfMonitorVolumeValue" class="wf-monitor-value">82</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-meter-wrap">
|
||
<span class="wf-monitor-label">S-Meter</span>
|
||
<div class="wf-rx-smeter">
|
||
<div class="wf-rx-smeter-fill" id="wfSmeterBar"></div>
|
||
</div>
|
||
<div class="wf-rx-smeter-text" id="wfSmeterText">S0</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-actions">
|
||
<div class="wf-rx-action-row">
|
||
<button class="wf-monitor-btn" id="wfMonitorBtn" onclick="Waterfall.toggleMonitor()">Monitor</button>
|
||
<button class="wf-monitor-btn wf-monitor-btn-secondary" id="wfMuteBtn" onclick="Waterfall.toggleMute()">Mute</button>
|
||
<button class="wf-monitor-btn wf-monitor-btn-unlock" id="wfAudioUnlockBtn" onclick="Waterfall.unlockAudio()" style="display:none;">Unlock Audio</button>
|
||
</div>
|
||
<div class="wf-monitor-state" id="wfMonitorState">No audio monitor</div>
|
||
</div>
|
||
<audio id="wfAudioPlayer" autoplay playsinline></audio>
|
||
</div>
|
||
|
||
<!-- Frequency control bar -->
|
||
<div class="wf-freq-bar">
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-10)" title="Step down ×10">«</button>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-1)" title="Step down">‹</button>
|
||
<div class="wf-freq-display-wrap">
|
||
<span class="wf-freq-bar-label">CENTER</span>
|
||
<input type="text" id="wfFreqCenterDisplay" class="wf-freq-center-input" value="100.0000" inputmode="decimal" autocomplete="off" spellcheck="false">
|
||
<span class="wf-freq-bar-unit">MHz</span>
|
||
</div>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(1)" title="Step up">›</button>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(10)" title="Step up ×10">»</button>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">ZOOM</span>
|
||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomOut && Waterfall.zoomOut()" title="Zoom out (wider span)">-</button>
|
||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomIn && Waterfall.zoomIn()" title="Zoom in (narrower span)">+</button>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">STEP</span>
|
||
<select id="wfStepSize" class="wf-step-select">
|
||
<option value="0.001">1 kHz</option>
|
||
<option value="0.005">5 kHz</option>
|
||
<option value="0.01">10 kHz</option>
|
||
<option value="0.025">25 kHz</option>
|
||
<option value="0.05">50 kHz</option>
|
||
<option value="0.1" selected>100 kHz</option>
|
||
<option value="0.5">500 kHz</option>
|
||
<option value="1">1 MHz</option>
|
||
<option value="5">5 MHz</option>
|
||
</select>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">SPAN</span>
|
||
<span id="wfSpanDisplay" class="wf-span-display">2.4 MHz</span>
|
||
</div>
|
||
|
||
<!-- Spectrum canvas -->
|
||
<div class="wf-spectrum-canvas-wrap">
|
||
<canvas id="wfSpectrumCanvas"></canvas>
|
||
<div class="wf-center-line"></div>
|
||
<div class="wf-tune-line" id="wfTuneLineSpec"></div>
|
||
</div>
|
||
|
||
<div class="wf-band-strip" id="wfBandStrip"></div>
|
||
|
||
<!-- Drag handle to resize spectrum vs waterfall -->
|
||
<div class="wf-resize-handle" id="wfResizeHandle">
|
||
<div class="wf-resize-grip"></div>
|
||
</div>
|
||
|
||
<!-- Waterfall canvas -->
|
||
<div class="wf-waterfall-canvas-wrap">
|
||
<canvas id="wfWaterfallCanvas"></canvas>
|
||
<div class="wf-tooltip" id="wfTooltip"></div>
|
||
<div class="wf-center-line"></div>
|
||
<div class="wf-tune-line" id="wfTuneLineWf"></div>
|
||
</div>
|
||
|
||
<div class="wf-freq-axis" id="wfFreqAxis"></div>
|
||
</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;">
|
||
<h4><span id="reconCollapseIcon">▶</span> Device Intelligence</h4>
|
||
<div class="recon-stats">
|
||
<div>TRACKED: <span id="trackedCount">0</span></div>
|
||
<div>NEW: <span id="newDeviceCount">0</span></div>
|
||
<div>ANOMALIES: <span id="anomalyCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="recon-content" id="reconContent">
|
||
<div style="color: #444; text-align: center; padding: 20px; font-size: 11px;">
|
||
Device intelligence data will appear here as signals are intercepted.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
|
||
<div id="filterBarContainer" style="display: none;"></div>
|
||
|
||
<!-- Pager Signal Scope -->
|
||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="scopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="pagerScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode-specific Timeline Containers -->
|
||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||
|
||
<!-- Sensor Signal Scope -->
|
||
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||
<span id="sensorScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sensorScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||
|
||
<!-- Morse Signal Scope -->
|
||
<div id="morseScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>CW Tone Detection</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>TONE: <span id="morseScopeToneLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span></span>
|
||
<span>THRESH: <span id="morseScopeThreshLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span></span>
|
||
<span id="morseScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="morseScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="morseDiagLog" style="display: none; margin-bottom: 8px; max-height: 60px; overflow-y: auto;
|
||
background: #080812; border: 1px solid #1a1a2e; border-radius: 4px; padding: 4px 8px;
|
||
font-family: var(--font-mono); font-size: 10px; color: #556677; line-height: 1.6;">
|
||
</div>
|
||
|
||
<!-- Morse Decoded Output -->
|
||
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Decoded Text</span>
|
||
<div style="display: flex; gap: 6px;">
|
||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportTxt()">TXT</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportCsv()">CSV</button>
|
||
<button class="btn btn-sm btn-ghost" id="morseCopyBtn" onclick="MorseMode.copyToClipboard()">Copy</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="MorseMode.clearText()">Clear</button>
|
||
</div>
|
||
</div>
|
||
<div id="morseDecodedText" class="morse-decoded-panel"></div>
|
||
<div id="morseRawPanel" class="morse-raw-panel" style="display: none;">
|
||
<div class="morse-raw-label">Raw Elements</div>
|
||
<div id="morseRawText" class="morse-raw-text"></div>
|
||
</div>
|
||
<div id="morseMetricsPanel" class="morse-metrics-panel">
|
||
<span id="morseMetricState">STATE idle</span>
|
||
<span id="morseMetricTone">TONE -- Hz</span>
|
||
<span id="morseMetricLevel">LEVEL --</span>
|
||
<span id="morseMetricThreshold">THRESH --</span>
|
||
<span id="morseMetricNoise">NOISE --</span>
|
||
<span id="morseMetricStopMs">STOP -- ms</span>
|
||
</div>
|
||
<div class="morse-status-bar">
|
||
<span class="status-item" id="morseStatusBarState">IDLE</span>
|
||
<span class="status-item" id="morseStatusBarWpm">-- WPM</span>
|
||
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
|
||
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Radiosonde Visuals -->
|
||
<div id="radiosondeVisuals" class="radiosonde-visuals-container" style="display: none;">
|
||
<div class="radiosonde-strip">
|
||
<div class="radiosonde-strip-inner">
|
||
<div class="strip-status">
|
||
<div class="status-dot" id="radiosondeStripDot"></div>
|
||
<span id="radiosondeStripStatus">STANDBY</span>
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="radiosondeStripBalloons">0</span>
|
||
<span class="strip-label">BALLOONS</span>
|
||
</div>
|
||
<div class="strip-stat">
|
||
<span class="strip-value" id="radiosondeStripUpdate">--:--:--</span>
|
||
<span class="strip-label">LAST UPDATE</span>
|
||
</div>
|
||
<div class="strip-divider"></div>
|
||
<div class="strip-waveform" id="radiosondeStripWaveform"></div>
|
||
</div>
|
||
</div>
|
||
<div id="radiosondeMapContainer" style="flex: 1; min-height: 300px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-primary);"></div>
|
||
<div id="radiosondeCardContainer" class="radiosonde-card-container"></div>
|
||
</div>
|
||
|
||
<!-- Meteor Scatter Visuals -->
|
||
<div id="meteorVisuals" class="meteor-visuals-container" style="display: none;">
|
||
<div class="ms-headline">
|
||
<div class="ms-headline-left">
|
||
<span id="meteorStatusChip" class="ms-headline-tag idle">IDLE</span>
|
||
<span class="ms-headline-sub" id="meteorFreqLabel">143.050 MHz</span>
|
||
</div>
|
||
<div class="ms-headline-right">
|
||
<span id="meteorStateTag" class="ms-headline-tag idle">IDLE</span>
|
||
<button id="meteorStartBtn" class="preset-btn" style="font-size: 10px; padding: 2px 10px;">Start</button>
|
||
<button id="meteorStopBtn" class="preset-btn" style="font-size: 10px; padding: 2px 10px;" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="ms-stats-strip">
|
||
<div class="ms-stat-cell">
|
||
<span class="ms-stat-label">Total Pings</span>
|
||
<span class="ms-stat-value highlight" id="meteorStatPingsTotal">0</span>
|
||
</div>
|
||
<div class="ms-stat-cell">
|
||
<span class="ms-stat-label">Last 10 Min</span>
|
||
<span class="ms-stat-value" id="meteorStatPings10min">0</span>
|
||
</div>
|
||
<div class="ms-stat-cell">
|
||
<span class="ms-stat-label">Strongest SNR</span>
|
||
<span class="ms-stat-value" id="meteorStatStrongest">0.0 dB</span>
|
||
</div>
|
||
<div class="ms-stat-cell">
|
||
<span class="ms-stat-label">Noise Floor</span>
|
||
<span class="ms-stat-value" id="meteorStatNoiseFloor">-100.0 dB</span>
|
||
</div>
|
||
<div class="ms-stat-cell">
|
||
<span class="ms-stat-label">Uptime</span>
|
||
<span class="ms-stat-value" id="meteorStatUptime">0:00</span>
|
||
</div>
|
||
</div>
|
||
<div class="ms-spectrum-wrap">
|
||
<canvas id="meteorSpectrumCanvas"></canvas>
|
||
</div>
|
||
<div class="ms-waterfall-wrap">
|
||
<canvas id="meteorWaterfallCanvas"></canvas>
|
||
<div class="ms-empty-state" id="meteorEmptyState">
|
||
<div class="ms-empty-icon">☄</div>
|
||
<div class="ms-empty-text">
|
||
Configure frequency and press Start to begin meteor scatter monitoring.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ms-timeline-wrap">
|
||
<canvas id="meteorTimelineCanvas"></canvas>
|
||
</div>
|
||
<div class="ms-events-panel">
|
||
<div class="ms-events-header">
|
||
<span class="ms-events-title">Detected Pings</span>
|
||
<span class="ms-events-count" id="meteorEventsCount">0 events</span>
|
||
</div>
|
||
<div class="ms-events-scroll">
|
||
<table class="ms-events-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Duration</th>
|
||
<th>SNR</th>
|
||
<th>Offset</th>
|
||
<th>Conf</th>
|
||
<th>Tags</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="meteorEventsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- System Health Visuals -->
|
||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||
<div class="sys-dashboard">
|
||
<!-- Row 1: COMPUTE -->
|
||
<div class="sys-group-header">Compute</div>
|
||
<div class="sys-card" id="sysCardCpu">
|
||
<div class="sys-card-header">CPU</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
<div class="sys-card" id="sysCardMemory">
|
||
<div class="sys-card-header">Memory</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
<div class="sys-card" id="sysCardTemp">
|
||
<div class="sys-card-header">Temperature & Power</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
|
||
<!-- Row 2: NETWORK & LOCATION -->
|
||
<div class="sys-group-header">Network & Location</div>
|
||
<div class="sys-card" id="sysCardNetwork">
|
||
<div class="sys-card-header">Network</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
<div class="sys-card" id="sysCardLocation">
|
||
<div class="sys-card-header">Location & Weather</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Loading…</span></div>
|
||
</div>
|
||
<div class="sys-card" id="sysCardInfo">
|
||
<div class="sys-card-header">System Info</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
|
||
<!-- Row 3: EQUIPMENT & OPERATIONS -->
|
||
<div class="sys-group-header">Equipment & Operations</div>
|
||
<div class="sys-card" id="sysCardDisk">
|
||
<div class="sys-card-header">Disk & Storage</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
<div class="sys-card" id="sysCardSdr">
|
||
<div class="sys-card-header">SDR Devices</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
||
</div>
|
||
<div class="sys-card" id="sysCardProcesses">
|
||
<div class="sys-card-header">Active Processes</div>
|
||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OOK Decoder Output Panel -->
|
||
<div id="ookOutputPanel" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 10px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; height: 100%; min-height: 0;">
|
||
<!-- Toolbar row 1: bit order -->
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Decoded Frames <span id="ookStatusBarFrames" style="color: var(--text-dim);">0 frames</span></span>
|
||
<div style="display: flex; gap: 6px; align-items: center;">
|
||
<span style="color: var(--text-dim); font-size: 10px;">Bit order:</span>
|
||
<button class="btn btn-sm btn-ghost" id="ookBitMSB"
|
||
onclick="OokMode.setBitOrder('msb')"
|
||
style="background: var(--accent); color: #000;">MSB</button>
|
||
<button class="btn btn-sm btn-ghost" id="ookBitLSB"
|
||
onclick="OokMode.setBitOrder('lsb')">LSB</button>
|
||
<button class="btn btn-sm btn-ghost" onclick="OokMode.suggestBitOrder()"
|
||
title="Auto-detect best bit order from printable character count">
|
||
Suggest <span id="ookSuggestLabel" style="font-size:9px; margin-left:2px;"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Toolbar row 2: pattern filter -->
|
||
<div style="margin-bottom: 6px;">
|
||
<input type="text" id="ookPatternFilter"
|
||
placeholder="Filter hex or ASCII..."
|
||
oninput="OokMode.filterFrames(this.value)"
|
||
style="width: 100%; background: #111; border: 1px solid #222; border-radius: 3px; color: var(--text-dim); font-family: var(--font-mono); font-size: 10px; padding: 3px 6px; box-sizing: border-box;">
|
||
</div>
|
||
<div id="ookOutput" style="flex: 1; min-height: 0; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="output-content signal-feed" id="output">
|
||
<div class="placeholder signal-empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>
|
||
</svg>
|
||
<p>Configure settings and click "Start Decoding" to begin.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar">
|
||
<div class="status-indicator">
|
||
<div class="status-dot" id="statusDot"></div>
|
||
<span id="statusText">Idle</span>
|
||
</div>
|
||
<div class="status-controls">
|
||
<div class="control-group">
|
||
<span class="control-group-label">Mode</span>
|
||
<button id="reconBtn" class="recon-toggle" onclick="toggleRecon()">RECON</button>
|
||
<button id="muteBtn" class="control-btn" onclick="toggleMute()"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span> MUTE</button>
|
||
<button id="autoScrollBtn" class="control-btn active" onclick="toggleAutoScroll()"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></span> AUTO</button>
|
||
</div>
|
||
<div class="control-group">
|
||
<span class="control-group-label">Export</span>
|
||
<button class="control-btn" onclick="exportCSV()">CSV</button>
|
||
<button class="control-btn" onclick="exportJSON()">JSON</button>
|
||
<button class="control-btn" onclick="exportDeviceDB()"
|
||
title="Export Device Intelligence">INTEL</button>
|
||
</div>
|
||
<button class="clear-btn" onclick="clearMessages()">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intercept JS Modules -->
|
||
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-guess.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/activity-timeline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/bluetooth-adapter.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
|
||
<!-- Bluetooth v2 components -->
|
||
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/consumption-sparkline.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/meter-aggregator.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/components/signal-waveform.js') }}"></script>
|
||
<!-- Mode scripts are lazy-loaded via ensureModeScript() in switchMode() -->
|
||
<!-- WiFi v2 components (eagerly loaded — shared component) -->
|
||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||
|
||
<script>
|
||
// ============================================
|
||
// ACTIVITY TIMELINE MANAGEMENT
|
||
// ============================================
|
||
const modeTimelines = {};
|
||
|
||
/**
|
||
* Initialize timeline for a specific mode
|
||
*/
|
||
function initializeModeTimeline(mode) {
|
||
// Skip if already initialized
|
||
if (modeTimelines[mode]) return;
|
||
|
||
const configs = {
|
||
'pager': {
|
||
container: 'pagerTimelineContainer',
|
||
config: {
|
||
title: 'Pager Activity',
|
||
mode: 'pager',
|
||
visualMode: 'enriched',
|
||
collapsed: false,
|
||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||
defaultWindow: '15m'
|
||
}
|
||
},
|
||
'sensor': {
|
||
container: 'sensorTimelineContainer',
|
||
config: {
|
||
title: 'Sensor Activity',
|
||
mode: 'sensor',
|
||
visualMode: 'enriched',
|
||
collapsed: false,
|
||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||
defaultWindow: '15m'
|
||
}
|
||
},
|
||
'tscm': {
|
||
container: 'tscmTimelineContainer',
|
||
config: typeof RFTimelineAdapter !== 'undefined' ? RFTimelineAdapter.getTscmConfig() : {
|
||
title: 'Signal Activity Timeline',
|
||
mode: 'tscm',
|
||
visualMode: 'enriched',
|
||
collapsed: true
|
||
}
|
||
},
|
||
'bluetooth': {
|
||
container: 'bluetoothTimelineContainer',
|
||
config: typeof BluetoothTimelineAdapter !== 'undefined' ? BluetoothTimelineAdapter.getBluetoothConfig() : {
|
||
title: 'Device Activity',
|
||
mode: 'bluetooth',
|
||
visualMode: 'enriched',
|
||
collapsed: false
|
||
}
|
||
},
|
||
'wifi': {
|
||
container: 'wifiTimelineContainer',
|
||
config: typeof WiFiTimelineAdapter !== 'undefined' ? WiFiTimelineAdapter.getWiFiConfig() : {
|
||
title: 'Network Activity',
|
||
mode: 'wifi',
|
||
visualMode: 'enriched',
|
||
collapsed: false
|
||
}
|
||
}
|
||
};
|
||
|
||
const modeConfig = configs[mode];
|
||
if (!modeConfig) return;
|
||
|
||
const container = document.getElementById(modeConfig.container);
|
||
if (!container) return;
|
||
|
||
// Create timeline using new ActivityTimeline
|
||
// For TSCM mode, use SignalTimeline.create() to ensure backward compatibility
|
||
// with SignalTimeline.addEvent() calls used in TSCM event handlers
|
||
if (mode === 'tscm' && typeof SignalTimeline !== 'undefined') {
|
||
SignalTimeline.create(modeConfig.container, modeConfig.config);
|
||
modeTimelines[mode] = { addEvent: (e) => SignalTimeline.addEvent(e.id, e.strength, e.duration, e.label) };
|
||
} else if (typeof ActivityTimeline !== 'undefined') {
|
||
modeTimelines[mode] = ActivityTimeline.create(modeConfig.container, modeConfig.config);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add event to a mode's timeline
|
||
*/
|
||
function addTimelineEvent(mode, eventData) {
|
||
const timeline = modeTimelines[mode];
|
||
if (timeline) {
|
||
timeline.addEvent(eventData);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get timeline instance for a mode
|
||
*/
|
||
function getTimeline(mode) {
|
||
return modeTimelines[mode] || null;
|
||
}
|
||
|
||
// Selected mode from welcome screen
|
||
const savedDefaultMode = localStorage.getItem('intercept.default_mode');
|
||
let selectedStartMode = savedDefaultMode === 'listening' ? 'waterfall' : (savedDefaultMode || 'pager');
|
||
if (savedDefaultMode === 'listening') {
|
||
localStorage.setItem('intercept.default_mode', 'waterfall');
|
||
}
|
||
|
||
// Mode selection from welcome page
|
||
function selectMode(mode) {
|
||
if (mode === 'satellite') {
|
||
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||
return;
|
||
}
|
||
selectedStartMode = mode;
|
||
const welcome = document.getElementById('welcomePage');
|
||
welcome.classList.add('fade-out');
|
||
|
||
// After fade out, hide welcome and switch to mode
|
||
setTimeout(() => {
|
||
welcome.style.display = 'none';
|
||
switchMode(mode, { updateUrl: true });
|
||
}, 400);
|
||
}
|
||
|
||
// Disclaimer handling - show on page load if not accepted
|
||
function showDisclaimer() {
|
||
document.getElementById('disclaimerModal').style.display = 'flex';
|
||
}
|
||
|
||
// Mode from query string (e.g., /?mode=wifi)
|
||
let pendingStartMode = null;
|
||
const modeCatalog = {
|
||
pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' },
|
||
sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' },
|
||
rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals' },
|
||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
|
||
radiosonde: { label: 'Radiosonde', indicator: 'SONDE', outputTitle: 'Radiosonde Decoder', group: 'tracking' },
|
||
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
|
||
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
|
||
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
|
||
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
|
||
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
|
||
meteor: { label: 'Meteor', indicator: 'METEOR', outputTitle: 'Meteor Scatter Monitor', group: 'space' },
|
||
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
|
||
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
|
||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
||
wifi_locate: { label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', group: 'wireless' },
|
||
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
|
||
system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
|
||
ook: { label: 'OOK Decoder', indicator: 'OOK', outputTitle: 'OOK Signal Decoder', group: 'signals' },
|
||
};
|
||
const validModes = new Set(Object.keys(modeCatalog));
|
||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||
|
||
function getModeFromQuery() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const requestedMode = params.get('mode');
|
||
const mode = requestedMode === 'listening' ? 'waterfall' : requestedMode;
|
||
if (!mode || !validModes.has(mode)) return null;
|
||
return mode;
|
||
}
|
||
|
||
function applyModeFromQuery() {
|
||
const mode = getModeFromQuery();
|
||
if (!mode) return;
|
||
if (mode === 'satellite') {
|
||
window.location.replace('/satellite/dashboard');
|
||
return;
|
||
}
|
||
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||
if (accepted) {
|
||
const welcome = document.getElementById('welcomePage');
|
||
if (welcome) welcome.style.display = 'none';
|
||
// Remove mode-gate style injected to prevent welcome flash
|
||
const modeGate = document.getElementById('mode-gate');
|
||
if (modeGate) modeGate.remove();
|
||
switchMode(mode, { updateUrl: false });
|
||
updateModeUrl(mode, true);
|
||
} else {
|
||
pendingStartMode = mode;
|
||
}
|
||
}
|
||
|
||
function applySettingsFromQuery() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get('settings') === '1') {
|
||
// Remove settings param from URL to avoid reopening on refresh
|
||
params.delete('settings');
|
||
const newUrl = params.toString()
|
||
? window.location.pathname + '?' + params.toString()
|
||
: window.location.pathname;
|
||
window.history.replaceState({}, '', newUrl);
|
||
// Open settings modal after a brief delay to ensure page is ready
|
||
setTimeout(() => {
|
||
if (typeof showSettings === 'function') {
|
||
showSettings();
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
function acceptDisclaimer() {
|
||
localStorage.setItem('disclaimerAccepted', 'true');
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
|
||
// After fade out, hide disclaimer and show welcome page
|
||
setTimeout(() => {
|
||
document.getElementById('disclaimerModal').style.display = 'none';
|
||
// Remove the gate CSS that was hiding welcome page
|
||
const gateStyle = document.getElementById('disclaimer-gate');
|
||
if (gateStyle) gateStyle.remove();
|
||
// Ensure welcome page is visible
|
||
const welcome = document.getElementById('welcomePage');
|
||
if (welcome) welcome.style.display = '';
|
||
if (pendingStartMode) {
|
||
// Bypass welcome and jump to requested mode
|
||
welcome.style.display = 'none';
|
||
switchMode(pendingStartMode, { updateUrl: true });
|
||
pendingStartMode = null;
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function declineDisclaimer() {
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||
}
|
||
|
||
// Show disclaimer on page load if not yet accepted
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
if (window._showDisclaimerOnLoad) {
|
||
showDisclaimer();
|
||
}
|
||
});
|
||
|
||
let eventSource = null;
|
||
let isRunning = false;
|
||
let isSensorRunning = false;
|
||
let isWifiRunning = false;
|
||
let isBtRunning = false;
|
||
let currentMode = 'pager';
|
||
let msgCount = 0;
|
||
let pocsagCount = 0;
|
||
let flexCount = 0;
|
||
let sensorCount = 0;
|
||
let filteredCount = 0; // Count of filtered messages
|
||
let deviceList = {{ devices | tojson | safe }};
|
||
|
||
// Pager message filter settings
|
||
let pagerFilters = {
|
||
hideToneOnly: false,
|
||
keywords: []
|
||
};
|
||
|
||
// UTC Clock Update
|
||
function updateHeaderClock() {
|
||
const now = new Date();
|
||
const utc = now.toISOString().substring(11, 19);
|
||
document.getElementById('headerUtcTime').textContent = utc;
|
||
}
|
||
|
||
function setActiveModeIndicator(label) {
|
||
const indicator = document.getElementById('activeModeIndicator');
|
||
if (!indicator) return;
|
||
|
||
indicator.textContent = '';
|
||
const dot = document.createElement('span');
|
||
dot.className = 'pulse-dot';
|
||
indicator.appendChild(dot);
|
||
indicator.appendChild(document.createTextNode(String(label || '')));
|
||
}
|
||
|
||
function applyKeyboardAccessibility(root = document) {
|
||
const interactive = root.querySelectorAll('[onclick]:not(button):not(a):not(input):not(select):not(textarea)');
|
||
interactive.forEach((el) => {
|
||
if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
|
||
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
|
||
el.setAttribute('data-keyboard-activate', 'true');
|
||
});
|
||
}
|
||
|
||
if (!window._keyboardActivationBound) {
|
||
window._keyboardActivationBound = true;
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
const target = event.target && event.target.closest ? event.target.closest('[data-keyboard-activate="true"]') : null;
|
||
if (!target) return;
|
||
event.preventDefault();
|
||
target.click();
|
||
});
|
||
}
|
||
|
||
// Update clock every second
|
||
setInterval(updateHeaderClock, 1000);
|
||
updateHeaderClock(); // Initial call
|
||
applyKeyboardAccessibility();
|
||
|
||
// Pager message filter functions
|
||
function loadPagerFilters() {
|
||
const saved = localStorage.getItem('pagerFilters');
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
// Only persist keywords across sessions.
|
||
// hideToneOnly defaults to false every session so users
|
||
// always see the full traffic stream unless they opt-in.
|
||
if (Array.isArray(parsed.keywords)) pagerFilters.keywords = parsed.keywords;
|
||
} catch (e) {
|
||
console.warn('Failed to load pager filters:', e);
|
||
}
|
||
}
|
||
// Update UI
|
||
document.getElementById('filterToneOnly').checked = pagerFilters.hideToneOnly;
|
||
document.getElementById('filterKeywords').value = pagerFilters.keywords.join(', ');
|
||
}
|
||
|
||
function savePagerFilters() {
|
||
pagerFilters.hideToneOnly = document.getElementById('filterToneOnly').checked;
|
||
const keywordsInput = document.getElementById('filterKeywords').value;
|
||
pagerFilters.keywords = keywordsInput
|
||
.split(',')
|
||
.map(k => k.trim().toLowerCase())
|
||
.filter(k => k.length > 0);
|
||
localStorage.setItem('pagerFilters', JSON.stringify(pagerFilters));
|
||
}
|
||
|
||
function shouldFilterMessage(msg) {
|
||
// Check for Tone Only filter
|
||
if (pagerFilters.hideToneOnly) {
|
||
if (msg.message === '[Tone Only]' || msg.msg_type === 'Tone') {
|
||
return true;
|
||
}
|
||
}
|
||
// Check keyword filters
|
||
if (pagerFilters.keywords.length > 0) {
|
||
const msgLower = (msg.message || '').toLowerCase();
|
||
for (const keyword of pagerFilters.keywords) {
|
||
if (msgLower.includes(keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Sync header stats with output panel stats
|
||
function syncHeaderStats() {
|
||
// Pager stats
|
||
const headerMsgCount = document.getElementById('headerMsgCount');
|
||
const headerPocsagCount = document.getElementById('headerPocsagCount');
|
||
const headerFlexCount = document.getElementById('headerFlexCount');
|
||
if (headerMsgCount) headerMsgCount.textContent = msgCount;
|
||
if (headerPocsagCount) headerPocsagCount.textContent = pocsagCount;
|
||
if (headerFlexCount) headerFlexCount.textContent = flexCount;
|
||
|
||
// Sensor stats
|
||
const headerSensorCount = document.getElementById('headerSensorCount');
|
||
const headerDeviceTypeCount = document.getElementById('headerDeviceTypeCount');
|
||
if (headerSensorCount) headerSensorCount.textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||
if (headerDeviceTypeCount) headerDeviceTypeCount.textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||
|
||
// WiFi stats
|
||
const headerApCount = document.getElementById('headerApCount');
|
||
const headerClientCount = document.getElementById('headerClientCount');
|
||
const headerHandshakeCount = document.getElementById('headerHandshakeCount');
|
||
const headerDroneCount = document.getElementById('headerDroneCount');
|
||
if (headerApCount) headerApCount.textContent = document.getElementById('apCount')?.textContent || '0';
|
||
if (headerClientCount) headerClientCount.textContent = document.getElementById('clientCount')?.textContent || '0';
|
||
if (headerHandshakeCount) headerHandshakeCount.textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||
if (headerDroneCount) headerDroneCount.textContent = document.getElementById('droneCount')?.textContent || '0';
|
||
|
||
// Satellite stats
|
||
const headerPassCount = document.getElementById('headerPassCount');
|
||
if (headerPassCount) headerPassCount.textContent = document.getElementById('passCount')?.textContent || '0';
|
||
}
|
||
// Sync stats periodically
|
||
setInterval(syncHeaderStats, 500);
|
||
|
||
// Update relative timestamps on signal cards every 30 seconds
|
||
setInterval(function() {
|
||
const output = document.getElementById('output');
|
||
if (output && typeof SignalCards !== 'undefined') {
|
||
SignalCards.updateTimestamps(output);
|
||
}
|
||
}, 30000);
|
||
|
||
// Observer location for distance calculations (load from localStorage or default to London)
|
||
let observerLocation = (function () {
|
||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||
return ObserverLocation.getForModule('observerLocation');
|
||
}
|
||
const saved = localStorage.getItem('observerLocation');
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
const lat = Number(parsed.lat);
|
||
const lon = Number(parsed.lon);
|
||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||
return { lat, lon };
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
return { lat: 51.5074, lon: -0.1278 };
|
||
})();
|
||
|
||
// GPS Dongle state
|
||
let gpsConnected = false;
|
||
let gpsEventSource = null;
|
||
let gpsAutoConnectTimer = null;
|
||
let gpsAutoConnectInFlight = null;
|
||
let gpsLastPosition = null;
|
||
|
||
// Satellite state
|
||
let satellitePasses = [];
|
||
let selectedPass = null;
|
||
let selectedPassIndex = 0;
|
||
let countdownInterval = null;
|
||
|
||
// Start satellite countdown timer
|
||
function startCountdownTimer() {
|
||
if (countdownInterval) clearInterval(countdownInterval);
|
||
countdownInterval = setInterval(updateSatelliteCountdown, 1000);
|
||
}
|
||
|
||
// Update satellite countdown display
|
||
function updateSatelliteCountdown() {
|
||
// Update both main and popout countdowns
|
||
updateCountdownDisplay('');
|
||
updateCountdownDisplay('Popout');
|
||
}
|
||
|
||
// Helper to update countdown elements by suffix
|
||
function updateCountdownDisplay(suffix) {
|
||
const container = document.getElementById('satelliteCountdown' + suffix);
|
||
if (!container) return;
|
||
|
||
// Use the globally selected pass
|
||
if (!selectedPass || satellitePasses.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const now = new Date();
|
||
const startTime = parsePassTime(selectedPass.startTime);
|
||
const endTime = new Date(startTime.getTime() + selectedPass.duration * 60000);
|
||
|
||
container.style.display = 'block';
|
||
document.getElementById('countdownSatName' + suffix).textContent = selectedPass.satellite;
|
||
|
||
if (now >= startTime && now <= endTime) {
|
||
// Currently visible
|
||
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));
|
||
const mins = Math.floor(remaining / 60);
|
||
const secs = remaining % 60;
|
||
|
||
document.getElementById('countdownToPass' + suffix).textContent = 'VISIBLE';
|
||
document.getElementById('countdownToPass' + suffix).classList.add('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = 'Now overhead';
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Remaining';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'SATELLITE CURRENTLY VISIBLE';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status visible';
|
||
|
||
} else if (startTime > now) {
|
||
// Upcoming pass
|
||
const secsToPass = Math.max(0, Math.floor((startTime - now) / 1000));
|
||
const hours = Math.floor(secsToPass / 3600);
|
||
const mins = Math.floor((secsToPass % 3600) / 60);
|
||
const secs = secsToPass % 60;
|
||
|
||
let countdownStr;
|
||
if (hours > 0) {
|
||
countdownStr = `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
} else {
|
||
countdownStr = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
document.getElementById('countdownToPass' + suffix).textContent = countdownStr;
|
||
document.getElementById('countdownToPass' + suffix).classList.remove('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
if (secsToPass < 300) {
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'PASS STARTING SOON';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status upcoming';
|
||
} else {
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'Selected pass';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
|
||
}
|
||
|
||
} else {
|
||
// Pass already happened
|
||
document.getElementById('countdownToPass' + suffix).textContent = 'PASSED';
|
||
document.getElementById('countdownToPass' + suffix).classList.remove('active');
|
||
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
|
||
|
||
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
|
||
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
|
||
|
||
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
|
||
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
|
||
|
||
document.getElementById('countdownStatus' + suffix).textContent = 'Pass has ended';
|
||
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
|
||
}
|
||
}
|
||
|
||
// Parse pass time string to Date object
|
||
function parsePassTime(timeStr) {
|
||
// Expected format: "2025-12-21 14:32 UTC"
|
||
// Remove "UTC" suffix and parse as ISO-like format
|
||
const cleanTime = timeStr.replace(' UTC', '').replace(' ', 'T') + ':00Z';
|
||
const parsed = new Date(cleanTime);
|
||
|
||
// Fallback if that doesn't work
|
||
if (isNaN(parsed.getTime())) {
|
||
// Try parsing as-is
|
||
return new Date(timeStr.replace(' UTC', ''));
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
const SIDEBAR_COLLAPSE_KEY = 'mainSidebarCollapsed';
|
||
|
||
function setMainSidebarCollapsed(collapsed) {
|
||
const mainContent = document.querySelector('.main-content');
|
||
const collapseBtn = document.getElementById('sidebarCollapseBtn');
|
||
if (!mainContent) return;
|
||
|
||
mainContent.classList.toggle('sidebar-collapsed', collapsed);
|
||
if (collapseBtn) {
|
||
collapseBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||
}
|
||
localStorage.setItem(SIDEBAR_COLLAPSE_KEY, collapsed ? 'true' : 'false');
|
||
}
|
||
|
||
function toggleMainSidebarCollapse(forceState = null) {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (!mainContent || window.innerWidth < 1024) return;
|
||
const collapsed = mainContent.classList.contains('sidebar-collapsed');
|
||
const nextState = forceState === null ? !collapsed : !!forceState;
|
||
setMainSidebarCollapsed(nextState);
|
||
}
|
||
|
||
function applySidebarCollapsePreference() {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (!mainContent) return;
|
||
if (window.innerWidth < 1024) {
|
||
mainContent.classList.remove('sidebar-collapsed');
|
||
return;
|
||
}
|
||
const savedCollapsed = localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true';
|
||
setMainSidebarCollapsed(savedCollapsed);
|
||
}
|
||
|
||
window.addEventListener('resize', applySidebarCollapsePreference);
|
||
|
||
// Make sections collapsible
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||
h3.addEventListener('click', function () {
|
||
this.parentElement.classList.toggle('collapsed');
|
||
});
|
||
});
|
||
|
||
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
|
||
document.querySelectorAll('.sidebar .section').forEach((section) => {
|
||
if (section.querySelector('h3')) {
|
||
section.classList.add('collapsed');
|
||
} else {
|
||
section.classList.remove('collapsed');
|
||
}
|
||
});
|
||
|
||
applySidebarCollapsePreference();
|
||
|
||
// Load bias-T setting from localStorage
|
||
loadBiasTSetting();
|
||
|
||
// Initialize device list from server-provided data
|
||
// This ensures currentDeviceList is populated on page load (fixes #99)
|
||
if (typeof deviceList !== 'undefined' && deviceList.length > 0) {
|
||
currentDeviceList = deviceList;
|
||
const firstType = deviceList[0].sdr_type || 'rtlsdr';
|
||
const sdrTypeSelect = document.getElementById('sdrTypeSelect');
|
||
if (sdrTypeSelect) {
|
||
sdrTypeSelect.value = firstType;
|
||
}
|
||
// Defer onSDRTypeChanged to ensure DOM is ready
|
||
setTimeout(onSDRTypeChanged, 0);
|
||
}
|
||
|
||
// Initialize observer location input fields from saved location
|
||
const obsLatInput = document.getElementById('obsLat');
|
||
const obsLonInput = document.getElementById('obsLon');
|
||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||
|
||
// Defer GPS auto-connect so it doesn't compete with initial dashboard navigation.
|
||
scheduleGpsAutoConnect();
|
||
|
||
// Load pager message filters
|
||
loadPagerFilters();
|
||
if (typeof SignalCards !== 'undefined') SignalCards.updateMutedIndicator();
|
||
|
||
// Initialize dropdown nav active state
|
||
updateDropdownActiveState();
|
||
|
||
// Restore nav group open/closed state from localStorage
|
||
initNavGroupState();
|
||
|
||
// Start SDR device status polling
|
||
startSdrStatusPolling();
|
||
|
||
// Apply mode from URL query (e.g., /?mode=wifi)
|
||
applyModeFromQuery();
|
||
|
||
// Check for settings=1 query param (from dashboard settings button)
|
||
applySettingsFromQuery();
|
||
});
|
||
|
||
// Toggle section collapse
|
||
function toggleSection(el) {
|
||
el.closest('.section').classList.toggle('collapsed');
|
||
}
|
||
|
||
// Dropdown navigation
|
||
function toggleNavDropdown(group) {
|
||
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${group}"]`);
|
||
const isOpen = dropdown.classList.contains('open');
|
||
|
||
// Close all dropdowns first
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
|
||
|
||
// Open this one if it was closed
|
||
if (!isOpen) {
|
||
dropdown.classList.add('open');
|
||
}
|
||
saveNavGroupState();
|
||
}
|
||
|
||
function initNavGroupState() {
|
||
const NAV_STATE_KEY = 'intercept_nav_groups';
|
||
let savedState = {};
|
||
try {
|
||
savedState = JSON.parse(localStorage.getItem(NAV_STATE_KEY) || '{}');
|
||
} catch (e) {
|
||
savedState = {};
|
||
}
|
||
|
||
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
|
||
const group = dropdown.dataset.group;
|
||
// If saved state says closed AND this group has no active item, close it
|
||
if (savedState[group] === false) {
|
||
const hasActive = dropdown.classList.contains('has-active');
|
||
if (!hasActive) {
|
||
dropdown.classList.remove('open');
|
||
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
|
||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
} else if (savedState[group] === true) {
|
||
dropdown.classList.add('open');
|
||
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
|
||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
}
|
||
|
||
function saveNavGroupState() {
|
||
const NAV_STATE_KEY = 'intercept_nav_groups';
|
||
const state = {};
|
||
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
|
||
state[dropdown.dataset.group] = dropdown.classList.contains('open');
|
||
});
|
||
try {
|
||
localStorage.setItem(NAV_STATE_KEY, JSON.stringify(state));
|
||
} catch (e) { /* storage full or unavailable */ }
|
||
}
|
||
|
||
function closeAllDropdowns() {
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
|
||
}
|
||
|
||
function updateDropdownActiveState() {
|
||
// Remove has-active from all dropdowns
|
||
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('has-active'));
|
||
|
||
// Add has-active to the dropdown containing the current mode
|
||
const activeGroup = modeCatalog[currentMode] ? modeCatalog[currentMode].group : null;
|
||
if (activeGroup) {
|
||
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${activeGroup}"]`);
|
||
if (dropdown) dropdown.classList.add('has-active');
|
||
}
|
||
}
|
||
|
||
// Close dropdowns when clicking outside
|
||
document.addEventListener('click', function (e) {
|
||
if (!e.target.closest('.mode-nav-dropdown')) {
|
||
closeAllDropdowns();
|
||
}
|
||
});
|
||
|
||
function updateModeUrl(mode, replace = false) {
|
||
if (!validModes.has(mode)) return;
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('mode', mode);
|
||
if (replace) {
|
||
window.history.replaceState({ mode }, '', url);
|
||
} else {
|
||
window.history.pushState({ mode }, '', url);
|
||
}
|
||
}
|
||
|
||
const LOCAL_STOP_TIMEOUT_MS = 2200;
|
||
const REMOTE_STOP_TIMEOUT_MS = 8000;
|
||
const DASHBOARD_NAV_PATHS = new Set([
|
||
'/adsb/dashboard',
|
||
'/ais/dashboard',
|
||
'/satellite/dashboard',
|
||
]);
|
||
|
||
// Shared module destroy map — closes SSE EventSources, timers, etc.
|
||
// Used by both switchMode() and dashboard navigation cleanup.
|
||
function getModuleDestroyFn(mode) {
|
||
const moduleDestroyMap = {
|
||
pager: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||
sensor: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||
rtlamr: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
|
||
subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
|
||
morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
|
||
spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
|
||
weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.destroy?.(),
|
||
wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(),
|
||
system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(),
|
||
waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(),
|
||
gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(),
|
||
meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(),
|
||
bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(),
|
||
wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(),
|
||
bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(),
|
||
wifi_locate: () => typeof WiFiLocate !== 'undefined' && WiFiLocate.destroy?.(),
|
||
sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(),
|
||
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
|
||
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
|
||
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
|
||
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
|
||
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||
aprs: () => {
|
||
if (typeof destroyAprsMode === 'function') {
|
||
destroyAprsMode();
|
||
} else if (aprsEventSource) {
|
||
aprsEventSource.close();
|
||
aprsEventSource = null;
|
||
}
|
||
},
|
||
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
||
};
|
||
return moduleDestroyMap[mode] || null;
|
||
}
|
||
|
||
function destroyCurrentMode() {
|
||
if (!currentMode) return;
|
||
const destroyFn = getModuleDestroyFn(currentMode);
|
||
if (destroyFn) {
|
||
try { destroyFn(); } catch(e) { console.warn(`[destroyCurrentMode] destroy ${currentMode} failed:`, e); }
|
||
}
|
||
}
|
||
|
||
function getActiveScanSummary() {
|
||
return {
|
||
pager: Boolean(isRunning),
|
||
sensor: Boolean(isSensorRunning),
|
||
morse: Boolean(
|
||
typeof MorseMode !== 'undefined'
|
||
&& typeof MorseMode.isActive === 'function'
|
||
&& MorseMode.isActive()
|
||
),
|
||
wifi: Boolean(
|
||
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
|
||
),
|
||
bluetooth: Boolean(
|
||
((typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) || isBtRunning)
|
||
),
|
||
aprs: Boolean(typeof isAprsRunning !== 'undefined' && isAprsRunning),
|
||
tscm: Boolean(typeof isTscmRunning !== 'undefined' && isTscmRunning),
|
||
};
|
||
}
|
||
|
||
function stopActiveLocalScansForNavigation() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
if (isAgentMode) return;
|
||
|
||
if (isRunning && typeof stopDecoding === 'function') {
|
||
Promise.resolve(stopDecoding()).catch(() => { });
|
||
}
|
||
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
|
||
Promise.resolve(stopSensorDecoding()).catch(() => { });
|
||
}
|
||
const morseActive = typeof MorseMode !== 'undefined'
|
||
&& typeof MorseMode.isActive === 'function'
|
||
&& MorseMode.isActive();
|
||
if (morseActive && typeof MorseMode.stop === 'function') {
|
||
Promise.resolve(MorseMode.stop()).catch(() => { });
|
||
}
|
||
|
||
const wifiScanActive = (
|
||
typeof WiFiMode !== 'undefined'
|
||
&& typeof WiFiMode.isScanning === 'function'
|
||
&& WiFiMode.isScanning()
|
||
) || isWifiRunning;
|
||
if (wifiScanActive && typeof stopWifiScan === 'function') {
|
||
Promise.resolve(stopWifiScan()).catch(() => { });
|
||
}
|
||
|
||
const btScanActive = (
|
||
typeof BluetoothMode !== 'undefined'
|
||
&& typeof BluetoothMode.isScanning === 'function'
|
||
&& BluetoothMode.isScanning()
|
||
) || isBtRunning;
|
||
if (btScanActive && typeof stopBtScan === 'function') {
|
||
Promise.resolve(stopBtScan()).catch(() => { });
|
||
}
|
||
|
||
if (typeof isAprsRunning !== 'undefined' && isAprsRunning && typeof stopAprs === 'function') {
|
||
Promise.resolve(stopAprs()).catch(() => { });
|
||
}
|
||
if (typeof isTscmRunning !== 'undefined' && isTscmRunning && typeof stopTscmSweep === 'function') {
|
||
Promise.resolve(stopTscmSweep()).catch(() => { });
|
||
}
|
||
|
||
// Additional modes with server-side processes that need stopping
|
||
if (typeof WeFax !== 'undefined' && typeof WeFax.stop === 'function') {
|
||
Promise.resolve(WeFax.stop()).catch(() => { });
|
||
}
|
||
if (typeof WeatherSat !== 'undefined' && typeof WeatherSat.stop === 'function') {
|
||
Promise.resolve(WeatherSat.stop()).catch(() => { });
|
||
}
|
||
if (typeof SSTV !== 'undefined' && typeof SSTV.stop === 'function') {
|
||
Promise.resolve(SSTV.stop()).catch(() => { });
|
||
}
|
||
if (typeof SSTVGeneral !== 'undefined' && typeof SSTVGeneral.stop === 'function') {
|
||
Promise.resolve(SSTVGeneral.stop()).catch(() => { });
|
||
}
|
||
if (typeof SubGhz !== 'undefined' && typeof SubGhz.stop === 'function') {
|
||
Promise.resolve(SubGhz.stop()).catch(() => { });
|
||
}
|
||
if (typeof Meshtastic !== 'undefined' && typeof Meshtastic.stop === 'function') {
|
||
Promise.resolve(Meshtastic.stop()).catch(() => { });
|
||
}
|
||
if (typeof GPS !== 'undefined' && typeof GPS.stop === 'function') {
|
||
Promise.resolve(GPS.stop()).catch(() => { });
|
||
}
|
||
}
|
||
|
||
if (!window._dashboardNavigationStopHookBound) {
|
||
window._dashboardNavigationStopHookBound = true;
|
||
document.addEventListener('click', (event) => {
|
||
if (event.defaultPrevented || event.button !== 0) return;
|
||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||
|
||
const link = event.target && event.target.closest
|
||
? event.target.closest('a[href]')
|
||
: null;
|
||
if (!link || link.target === '_blank') return;
|
||
|
||
try {
|
||
const href = new URL(link.href, window.location.href);
|
||
if (href.origin !== window.location.origin) return;
|
||
if (!DASHBOARD_NAV_PATHS.has(href.pathname)) return;
|
||
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||
window.InterceptNavPerf.markStart({
|
||
targetPath: href.pathname,
|
||
trigger: 'index-link',
|
||
sourceMode: currentMode,
|
||
activeScans: getActiveScanSummary(),
|
||
});
|
||
}
|
||
// Let dedicated dashboards navigate immediately.
|
||
// Pre-navigation stop requests from active modes like Pager
|
||
// can stall same-tab navigation badly on some browsers.
|
||
destroyCurrentMode();
|
||
} catch (_) {
|
||
// Ignore malformed hrefs.
|
||
}
|
||
});
|
||
}
|
||
|
||
function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||
const started = performance.now();
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
...(controller ? { signal: controller.signal } : {}),
|
||
})
|
||
.then((response) => response.json().catch(() => ({ status: response.ok ? 'ok' : 'error' })))
|
||
.catch((err) => {
|
||
if (err && err.name === 'AbortError') {
|
||
console.warn(`[Stop] ${url} timed out after ${timeoutMs}ms`);
|
||
return { status: 'timeout', timed_out: true };
|
||
}
|
||
console.warn(`[Stop] ${url} failed: ${err?.message || err}`);
|
||
return { status: 'error', message: err?.message || String(err) };
|
||
})
|
||
.finally(() => {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
const elapsedMs = Math.round(performance.now() - started);
|
||
console.debug(`[Stop] ${url} finished in ${elapsedMs}ms`);
|
||
});
|
||
}
|
||
|
||
async function awaitStopAction(name, action, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||
const started = performance.now();
|
||
try {
|
||
const result = action();
|
||
const promise = (result && typeof result.then === 'function')
|
||
? result
|
||
: Promise.resolve(result);
|
||
await Promise.race([
|
||
promise,
|
||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||
]);
|
||
} catch (err) {
|
||
console.warn(`[ModeSwitch] stop ${name} failed: ${err?.message || err}`);
|
||
} finally {
|
||
const elapsedMs = Math.round(performance.now() - started);
|
||
console.debug(`[ModeSwitch] stop ${name} finished in ${elapsedMs}ms`);
|
||
}
|
||
}
|
||
|
||
let modeSwitchRequestId = 0;
|
||
|
||
// Mode switching
|
||
async function switchMode(mode, options = {}) {
|
||
const requestId = ++modeSwitchRequestId;
|
||
const { updateUrl = true } = options;
|
||
const switchStartMs = performance.now();
|
||
const previousMode = currentMode;
|
||
if (mode === 'listening') mode = 'waterfall';
|
||
if (mode === 'satellite') {
|
||
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||
return;
|
||
}
|
||
if (!validModes.has(mode)) mode = 'pager';
|
||
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
|
||
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
|
||
console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`);
|
||
})
|
||
: Promise.resolve();
|
||
const scriptReadyPromise = (typeof window.ensureModeScript === 'function')
|
||
? Promise.resolve(window.ensureModeScript(mode)).catch((err) => {
|
||
console.warn(`[ModeSwitch] script load failed for ${mode}: ${err?.message || err}`);
|
||
})
|
||
: Promise.resolve();
|
||
// Only stop local scans if in local mode (not agent mode)
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const stopPhaseStartMs = performance.now();
|
||
let stopTaskCount = 0;
|
||
if (!isAgentMode) {
|
||
const stopTasks = [];
|
||
|
||
if (isRunning) {
|
||
stopTasks.push(awaitStopAction('pager', () => stopDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isSensorRunning) {
|
||
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const morseActive = typeof MorseMode !== 'undefined'
|
||
&& typeof MorseMode.isActive === 'function'
|
||
&& MorseMode.isActive();
|
||
if (morseActive && typeof MorseMode.stop === 'function') {
|
||
stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const wifiScanActive = (
|
||
typeof WiFiMode !== 'undefined'
|
||
&& typeof WiFiMode.isScanning === 'function'
|
||
&& WiFiMode.isScanning()
|
||
) || isWifiRunning;
|
||
const isWifiModeTransition =
|
||
(currentMode === 'wifi' && mode === 'wifi_locate') ||
|
||
(currentMode === 'wifi_locate' && mode === 'wifi');
|
||
if (wifiScanActive && !isWifiModeTransition) {
|
||
stopTasks.push(awaitStopAction('wifi', () => stopWifiScan(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const btScanActive = (typeof BluetoothMode !== 'undefined' &&
|
||
typeof BluetoothMode.isScanning === 'function' &&
|
||
BluetoothMode.isScanning()) || isBtRunning;
|
||
const isBtModeTransition =
|
||
(currentMode === 'bluetooth' && mode === 'bt_locate') ||
|
||
(currentMode === 'bt_locate' && mode === 'bluetooth');
|
||
if (btScanActive && !isBtModeTransition && typeof stopBtScan === 'function') {
|
||
stopTasks.push(awaitStopAction('bluetooth', () => stopBtScan(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isAprsRunning) {
|
||
stopTasks.push(awaitStopAction('aprs', () => stopAprs(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isTscmRunning) {
|
||
stopTasks.push(awaitStopAction('tscm', () => stopTscmSweep(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
|
||
if (stopTasks.length) {
|
||
await Promise.allSettled(stopTasks);
|
||
}
|
||
stopTaskCount = stopTasks.length;
|
||
}
|
||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||
await styleReadyPromise;
|
||
await scriptReadyPromise;
|
||
if (requestId !== modeSwitchRequestId) return;
|
||
|
||
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
|
||
if (previousMode && previousMode !== mode) {
|
||
const destroyFn = getModuleDestroyFn(previousMode);
|
||
if (destroyFn) {
|
||
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
||
}
|
||
}
|
||
if (requestId !== modeSwitchRequestId) return;
|
||
|
||
currentMode = mode;
|
||
document.body.setAttribute('data-mode', mode);
|
||
if (updateUrl) {
|
||
updateModeUrl(mode);
|
||
}
|
||
|
||
// Sync mode state with current agent/local after switching
|
||
if (isAgentMode && typeof syncAgentModeStates === 'function') {
|
||
// Re-sync with agent to update this mode's UI state
|
||
syncAgentModeStates(currentAgent);
|
||
} else if (!isAgentMode && typeof syncLocalModeStates === 'function') {
|
||
// Sync with local status
|
||
syncLocalModeStates();
|
||
}
|
||
if (requestId !== modeSwitchRequestId) return;
|
||
|
||
// Close dropdowns and update active state
|
||
closeAllDropdowns();
|
||
updateDropdownActiveState();
|
||
|
||
// Remove active from all nav buttons, then add to the correct one
|
||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||
});
|
||
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||
});
|
||
const activeMobileBtn = document.querySelector('.mobile-nav-btn.active');
|
||
if (activeMobileBtn) {
|
||
activeMobileBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||
}
|
||
document.getElementById('pagerMode')?.classList.toggle('active', mode === 'pager');
|
||
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
|
||
document.getElementById('wefaxMode')?.classList.toggle('active', mode === 'wefax');
|
||
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
|
||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
|
||
document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate');
|
||
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('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
|
||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
||
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
||
document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor');
|
||
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
|
||
document.getElementById('ookMode')?.classList.toggle('active', mode === 'ook');
|
||
|
||
|
||
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
|
||
document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('satelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('wifiStats')?.classList.toggle('active', mode === 'wifi');
|
||
|
||
// Update header stats groups
|
||
document.getElementById('headerPagerStats')?.classList.toggle('active', mode === 'pager');
|
||
document.getElementById('headerSensorStats')?.classList.toggle('active', mode === 'sensor');
|
||
document.getElementById('headerSatelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||
document.getElementById('headerWifiStats')?.classList.toggle('active', mode === 'wifi');
|
||
|
||
// Show/hide dashboard buttons in nav bar
|
||
const satelliteDashboardBtn = document.getElementById('satelliteDashboardBtn');
|
||
if (satelliteDashboardBtn) satelliteDashboardBtn.style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||
|
||
// Update active mode indicator
|
||
const modeMeta = modeCatalog[mode] || {};
|
||
setActiveModeIndicator(modeMeta.indicator || mode.toUpperCase());
|
||
const wifiLayoutContainer = document.getElementById('wifiLayoutContainer');
|
||
const btLayoutContainer = document.getElementById('btLayoutContainer');
|
||
const satelliteVisuals = document.getElementById('satelliteVisuals');
|
||
const aprsVisuals = document.getElementById('aprsVisuals');
|
||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||
const sstvVisuals = document.getElementById('sstvVisuals');
|
||
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
|
||
const gpsVisuals = document.getElementById('gpsVisuals');
|
||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||
const wflVisuals = document.getElementById('wflVisuals');
|
||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
|
||
const meteorVisuals = document.getElementById('meteorVisuals');
|
||
const systemVisuals = document.getElementById('systemVisuals');
|
||
if (wifiLayoutContainer) wifiLayoutContainer.classList.toggle('active', mode === 'wifi');
|
||
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
|
||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||
const satFrame = document.getElementById('satelliteDashboardFrame');
|
||
if (satFrame && mode === 'satellite') {
|
||
const baseSrc = satFrame.dataset.src || '/satellite/dashboard?embedded=true&v={{ version }}';
|
||
const currentSrc = satFrame.getAttribute('src') || '';
|
||
if (!currentSrc || currentSrc === 'about:blank') {
|
||
satFrame.src = `${baseSrc}&ts=${Date.now()}`;
|
||
}
|
||
} else if (satFrame) {
|
||
const currentSrc = satFrame.getAttribute('src') || '';
|
||
if (currentSrc && currentSrc !== 'about:blank') {
|
||
satFrame.src = 'about:blank';
|
||
}
|
||
}
|
||
if (satFrame && satFrame.contentWindow && satFrame.getAttribute('src') && satFrame.getAttribute('src') !== 'about:blank') {
|
||
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
|
||
}
|
||
|
||
// Weather-sat handoff: when switching away from satellite mode, clear any pending handoff banner
|
||
if (mode !== 'satellite' && mode !== 'weathersat') {
|
||
const existing = document.getElementById('weatherSatHandoffBanner');
|
||
if (existing) existing.remove();
|
||
}
|
||
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';
|
||
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
||
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
||
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
|
||
if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none';
|
||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||
if (wflVisuals) wflVisuals.style.display = mode === 'wifi_locate' ? 'flex' : 'none';
|
||
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||
if (radiosondeVisuals) radiosondeVisuals.style.display = mode === 'radiosonde' ? 'flex' : 'none';
|
||
if (meteorVisuals) meteorVisuals.style.display = mode === 'meteor' ? 'flex' : 'none';
|
||
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
|
||
|
||
// Hide the signal feed output for modes that have their own visuals
|
||
const outputEl = document.getElementById('output');
|
||
const modesWithVisuals = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'wifi_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook', 'radiosonde', 'gps'];
|
||
if (outputEl) outputEl.style.display = modesWithVisuals.includes(mode) ? 'none' : 'block';
|
||
|
||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||
BtLocate.setActiveMode(mode === 'bt_locate');
|
||
}
|
||
if (typeof WiFiLocate !== 'undefined' && WiFiLocate.setActiveMode) {
|
||
WiFiLocate.setActiveMode(mode === 'wifi_locate');
|
||
}
|
||
|
||
ide sidebar by default for Meshtastic mode, show for others
|
||
|