mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 23:29:59 -07:00
Remove legacy RF modes and add SignalID route/tests
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Activity Timeline Component
|
||||
* Reusable, configuration-driven timeline visualization for time-based metadata
|
||||
* Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
|
||||
* Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring
|
||||
*/
|
||||
|
||||
const ActivityTimeline = (function() {
|
||||
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
|
||||
*/
|
||||
function categorizeById(id, mode) {
|
||||
// RF frequency categorization
|
||||
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
|
||||
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
|
||||
const f = parseFloat(id);
|
||||
if (!isNaN(f)) {
|
||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* RF Signal Timeline Adapter
|
||||
* Normalizes RF signal data for the Activity Timeline component
|
||||
* Used by: Listening Post, TSCM
|
||||
*/
|
||||
/**
|
||||
* RF Signal Timeline Adapter
|
||||
* Normalizes RF signal data for the Activity Timeline component
|
||||
* Used by: Spectrum Waterfall, TSCM
|
||||
*/
|
||||
|
||||
const RFTimelineAdapter = (function() {
|
||||
'use strict';
|
||||
@@ -157,16 +157,16 @@ const RFTimelineAdapter = (function() {
|
||||
return signals.map(normalizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for Listening Post mode
|
||||
*/
|
||||
function getListeningPostConfig() {
|
||||
return {
|
||||
title: 'Signal Activity',
|
||||
mode: 'listening-post',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
/**
|
||||
* Create timeline configuration for spectrum waterfall mode.
|
||||
*/
|
||||
function getWaterfallConfig() {
|
||||
return {
|
||||
title: 'Spectrum Activity',
|
||||
mode: 'waterfall',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
showLegend: true,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||||
@@ -184,9 +184,14 @@ const RFTimelineAdapter = (function() {
|
||||
}
|
||||
],
|
||||
maxItems: 50,
|
||||
maxDisplayedLanes: 12
|
||||
};
|
||||
}
|
||||
maxDisplayedLanes: 12
|
||||
};
|
||||
}
|
||||
|
||||
// Backward compatibility alias for legacy callers.
|
||||
function getListeningPostConfig() {
|
||||
return getWaterfallConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for TSCM mode
|
||||
@@ -224,8 +229,9 @@ const RFTimelineAdapter = (function() {
|
||||
categorizeFrequency: categorizeFrequency,
|
||||
|
||||
// Configuration presets
|
||||
getListeningPostConfig: getListeningPostConfig,
|
||||
getTscmConfig: getTscmConfig,
|
||||
getWaterfallConfig: getWaterfallConfig,
|
||||
getListeningPostConfig: getListeningPostConfig,
|
||||
getTscmConfig: getTscmConfig,
|
||||
|
||||
// Constants
|
||||
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
||||
|
||||
@@ -98,7 +98,7 @@ function switchMode(mode) {
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
||||
'meshtastic': 'meshtastic'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -114,7 +114,6 @@ function switchMode(mode) {
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
@@ -143,7 +142,6 @@ function switchMode(mode) {
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
'tscm': 'TSCM',
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'MESHTASTIC'
|
||||
@@ -166,7 +164,6 @@ function switchMode(mode) {
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
@@ -176,7 +173,6 @@ function switchMode(mode) {
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post',
|
||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -184,7 +180,7 @@ function switchMode(mode) {
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
if (mode === 'satellite' || mode === 'aircraft') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -198,7 +194,7 @@ function switchMode(mode) {
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
@@ -207,7 +203,7 @@ function switchMode(mode) {
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
@@ -226,11 +222,6 @@ function switchMode(mode) {
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
} else if (mode === 'meshtastic') {
|
||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||
}
|
||||
|
||||
@@ -16,17 +16,14 @@ const CheatSheets = (function () {
|
||||
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['GPS feeds into RF Heatmap', 'BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118–136 MHz AM', 'Marine VHF: 156–174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Listening Post to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||
rfheatmap: { title: 'RF Heatmap', icon: '🗺️', hardware: 'GPS receiver + WiFi/BT/SDR', description: 'GPS-tagged signal strength heatmap. Walk to build coverage maps.', whatToExpect: 'Leaflet map with heat overlay showing signal by location.', tips: ['Connect GPS first, wait for fix', 'Set min sample distance to avoid duplicates', 'Export GeoJSON for use in QGIS'] },
|
||||
fingerprint: { title: 'RF Fingerprinting', icon: '🔬', hardware: 'RTL-SDR + Listening Post scanner', description: 'Records RF baselines and detects anomalies via statistical comparison.', whatToExpect: 'Band-by-band power comparison, z-score anomaly detection.', tips: ['Take baseline in a clean RF environment', 'Z-score ≥3 = statistically significant anomaly', 'New bands highlighted in purple'] },
|
||||
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||
};
|
||||
|
||||
function show(mode) {
|
||||
|
||||
@@ -12,8 +12,8 @@ const CommandPalette = (function() {
|
||||
{ mode: 'pager', label: 'Pager' },
|
||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||
{ mode: 'rtlamr', label: 'Meters' },
|
||||
{ mode: 'listening', label: 'Listening Post' },
|
||||
{ mode: 'subghz', label: 'SubGHz' },
|
||||
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
|
||||
{ mode: 'aprs', label: 'APRS' },
|
||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||
|
||||
@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
|
||||
['pager', 'Pager'],
|
||||
['sensor', '433MHz'],
|
||||
['rtlamr', 'Meters'],
|
||||
['listening', 'Listening Post'],
|
||||
['waterfall', 'Waterfall'],
|
||||
['wifi', 'WiFi'],
|
||||
['bluetooth', 'Bluetooth'],
|
||||
['bt_locate', 'BT Locate'],
|
||||
@@ -149,7 +149,11 @@ const FirstRunSetup = (function() {
|
||||
|
||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||
if (savedDefaultMode) {
|
||||
modeSelectEl.value = savedDefaultMode;
|
||||
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
|
||||
modeSelectEl.value = normalizedMode;
|
||||
if (normalizedMode !== savedDefaultMode) {
|
||||
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
|
||||
}
|
||||
}
|
||||
|
||||
actionsEl.appendChild(modeSelectEl);
|
||||
|
||||
@@ -8,14 +8,12 @@ const KeyboardShortcuts = (function () {
|
||||
function _handle(e) {
|
||||
if (e.target.matches(GUARD_SELECTOR)) return;
|
||||
|
||||
if (e.altKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
|
||||
case 'h': e.preventDefault(); window.switchMode && switchMode('rfheatmap'); break;
|
||||
case 'n': e.preventDefault(); window.switchMode && switchMode('fingerprint'); break;
|
||||
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
|
||||
case 's': e.preventDefault(); _toggleSidebar(); break;
|
||||
case 'k': e.preventDefault(); showHelp(); break;
|
||||
if (e.altKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
|
||||
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
|
||||
case 's': e.preventDefault(); _toggleSidebar(); break;
|
||||
case 'k': e.preventDefault(); showHelp(); break;
|
||||
case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
|
||||
default:
|
||||
if (e.key >= '1' && e.key <= '9') {
|
||||
|
||||
@@ -1,113 +1,145 @@
|
||||
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
|
||||
const VoiceAlerts = (function () {
|
||||
'use strict';
|
||||
|
||||
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
||||
let _enabled = true;
|
||||
let _muted = false;
|
||||
let _queue = [];
|
||||
let _speaking = false;
|
||||
let _sources = {};
|
||||
const STORAGE_KEY = 'intercept-voice-muted';
|
||||
const CONFIG_KEY = 'intercept-voice-config';
|
||||
|
||||
// Default config
|
||||
let _config = {
|
||||
rate: 1.1,
|
||||
pitch: 0.9,
|
||||
voiceName: '',
|
||||
streams: { pager: true, tscm: true, bluetooth: true },
|
||||
};
|
||||
|
||||
function _loadConfig() {
|
||||
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
try {
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
_config.rate = parsed.rate ?? _config.rate;
|
||||
_config.pitch = parsed.pitch ?? _config.pitch;
|
||||
_config.voiceName = parsed.voiceName ?? _config.voiceName;
|
||||
if (parsed.streams) {
|
||||
Object.assign(_config.streams, parsed.streams);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
_updateMuteButton();
|
||||
}
|
||||
|
||||
function _updateMuteButton() {
|
||||
const btn = document.getElementById('voiceMuteBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('voice-muted', _muted);
|
||||
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
|
||||
btn.style.opacity = _muted ? '0.4' : '1';
|
||||
}
|
||||
|
||||
function _getVoice() {
|
||||
if (!_config.voiceName) return null;
|
||||
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
|
||||
return voices.find(v => v.name === _config.voiceName) || null;
|
||||
}
|
||||
|
||||
function speak(text, priority) {
|
||||
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||
if (!_enabled || _muted) return;
|
||||
if (!window.speechSynthesis) return;
|
||||
if (priority === PRIORITY.LOW && _speaking) return;
|
||||
if (priority === PRIORITY.HIGH && _speaking) {
|
||||
window.speechSynthesis.cancel();
|
||||
_queue = [];
|
||||
_speaking = false;
|
||||
}
|
||||
_queue.push({ text, priority });
|
||||
if (!_speaking) _dequeue();
|
||||
}
|
||||
|
||||
function _dequeue() {
|
||||
if (_queue.length === 0) { _speaking = false; return; }
|
||||
_speaking = true;
|
||||
const item = _queue.shift();
|
||||
const utt = new SpeechSynthesisUtterance(item.text);
|
||||
utt.rate = _config.rate;
|
||||
utt.pitch = _config.pitch;
|
||||
const voice = _getVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
_muted = !_muted;
|
||||
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
|
||||
_updateMuteButton();
|
||||
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
function _openStream(url, handler, key) {
|
||||
if (_sources[key]) return;
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = handler;
|
||||
es.onerror = () => { es.close(); delete _sources[key]; };
|
||||
_sources[key] = es;
|
||||
}
|
||||
|
||||
function _startStreams() {
|
||||
if (!_enabled) return;
|
||||
|
||||
// Pager stream
|
||||
if (_config.streams.pager) {
|
||||
_openStream('/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.address && d.message) {
|
||||
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'pager');
|
||||
}
|
||||
|
||||
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
|
||||
const VoiceAlerts = (function () {
|
||||
'use strict';
|
||||
|
||||
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
||||
let _enabled = true;
|
||||
let _muted = false;
|
||||
let _queue = [];
|
||||
let _speaking = false;
|
||||
let _sources = {};
|
||||
const STORAGE_KEY = 'intercept-voice-muted';
|
||||
const CONFIG_KEY = 'intercept-voice-config';
|
||||
const RATE_MIN = 0.5;
|
||||
const RATE_MAX = 2.0;
|
||||
const PITCH_MIN = 0.5;
|
||||
const PITCH_MAX = 2.0;
|
||||
|
||||
// Default config
|
||||
let _config = {
|
||||
rate: 1.1,
|
||||
pitch: 0.9,
|
||||
voiceName: '',
|
||||
streams: { pager: true, tscm: true, bluetooth: true },
|
||||
};
|
||||
|
||||
function _toNumberInRange(value, fallback, min, max) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function _normalizeConfig() {
|
||||
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
|
||||
}
|
||||
|
||||
function _isSpeechSupported() {
|
||||
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
|
||||
}
|
||||
|
||||
function _showVoiceToast(title, message, type) {
|
||||
if (typeof window.showAppToast === 'function') {
|
||||
window.showAppToast(title, message, type || 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function _loadConfig() {
|
||||
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
try {
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
_config.rate = parsed.rate ?? _config.rate;
|
||||
_config.pitch = parsed.pitch ?? _config.pitch;
|
||||
_config.voiceName = parsed.voiceName ?? _config.voiceName;
|
||||
if (parsed.streams) {
|
||||
Object.assign(_config.streams, parsed.streams);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
_normalizeConfig();
|
||||
_updateMuteButton();
|
||||
}
|
||||
|
||||
function _updateMuteButton() {
|
||||
const btn = document.getElementById('voiceMuteBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('voice-muted', _muted);
|
||||
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
|
||||
btn.style.opacity = _muted ? '0.4' : '1';
|
||||
}
|
||||
|
||||
function _getVoice() {
|
||||
if (!_config.voiceName) return null;
|
||||
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
|
||||
return voices.find(v => v.name === _config.voiceName) || null;
|
||||
}
|
||||
|
||||
function _createUtterance(text) {
|
||||
const utt = new SpeechSynthesisUtterance(text);
|
||||
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||
const voice = _getVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
return utt;
|
||||
}
|
||||
|
||||
function speak(text, priority) {
|
||||
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||
if (!_enabled || _muted) return;
|
||||
if (!window.speechSynthesis) return;
|
||||
if (priority === PRIORITY.LOW && _speaking) return;
|
||||
if (priority === PRIORITY.HIGH && _speaking) {
|
||||
window.speechSynthesis.cancel();
|
||||
_queue = [];
|
||||
_speaking = false;
|
||||
}
|
||||
_queue.push({ text, priority });
|
||||
if (!_speaking) _dequeue();
|
||||
}
|
||||
|
||||
function _dequeue() {
|
||||
if (_queue.length === 0) { _speaking = false; return; }
|
||||
_speaking = true;
|
||||
const item = _queue.shift();
|
||||
const utt = _createUtterance(item.text);
|
||||
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
_muted = !_muted;
|
||||
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
|
||||
_updateMuteButton();
|
||||
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
function _openStream(url, handler, key) {
|
||||
if (_sources[key]) return;
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = handler;
|
||||
es.onerror = () => { es.close(); delete _sources[key]; };
|
||||
_sources[key] = es;
|
||||
}
|
||||
|
||||
function _startStreams() {
|
||||
if (!_enabled) return;
|
||||
|
||||
// Pager stream
|
||||
if (_config.streams.pager) {
|
||||
_openStream('/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.address && d.message) {
|
||||
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'pager');
|
||||
}
|
||||
|
||||
// TSCM stream
|
||||
if (_config.streams.tscm) {
|
||||
_openStream('/tscm/sweep/stream', (ev) => {
|
||||
@@ -116,85 +148,108 @@ const VoiceAlerts = (function () {
|
||||
if (d.threat_level && d.description) {
|
||||
speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'tscm');
|
||||
}
|
||||
|
||||
// Bluetooth stream — tracker detection only
|
||||
if (_config.streams.bluetooth) {
|
||||
_openStream('/api/bluetooth/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.service_data && d.service_data.tracker_type) {
|
||||
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'bluetooth');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function _stopStreams() {
|
||||
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
_sources = {};
|
||||
}
|
||||
|
||||
function init() {
|
||||
_loadConfig();
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
function setEnabled(val) {
|
||||
_enabled = val;
|
||||
if (!val) {
|
||||
_stopStreams();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
} else {
|
||||
_startStreams();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config API (used by Ops Center voice config panel) ─────────────
|
||||
|
||||
function getConfig() {
|
||||
return JSON.parse(JSON.stringify(_config));
|
||||
}
|
||||
|
||||
function setConfig(cfg) {
|
||||
if (cfg.rate !== undefined) _config.rate = cfg.rate;
|
||||
if (cfg.pitch !== undefined) _config.pitch = cfg.pitch;
|
||||
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||
// Restart streams to apply per-stream toggle changes
|
||||
_stopStreams();
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
function getAvailableVoices() {
|
||||
return new Promise(resolve => {
|
||||
if (!window.speechSynthesis) { resolve([]); return; }
|
||||
let voices = speechSynthesis.getVoices();
|
||||
if (voices.length > 0) { resolve(voices); return; }
|
||||
speechSynthesis.onvoiceschanged = () => {
|
||||
resolve(speechSynthesis.getVoices());
|
||||
};
|
||||
// Timeout fallback
|
||||
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
|
||||
});
|
||||
}
|
||||
|
||||
function testVoice(text) {
|
||||
if (!window.speechSynthesis) return;
|
||||
const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.');
|
||||
utt.rate = _config.rate;
|
||||
utt.pitch = _config.pitch;
|
||||
const voice = _getVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
speechSynthesis.speak(utt);
|
||||
}
|
||||
|
||||
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
})();
|
||||
|
||||
window.VoiceAlerts = VoiceAlerts;
|
||||
} catch (_) {}
|
||||
}, 'tscm');
|
||||
}
|
||||
|
||||
// Bluetooth stream — tracker detection only
|
||||
if (_config.streams.bluetooth) {
|
||||
_openStream('/api/bluetooth/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.service_data && d.service_data.tracker_type) {
|
||||
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'bluetooth');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function _stopStreams() {
|
||||
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
_sources = {};
|
||||
}
|
||||
|
||||
function init() {
|
||||
_loadConfig();
|
||||
if (_isSpeechSupported()) {
|
||||
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||
speechSynthesis.getVoices();
|
||||
}
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
function setEnabled(val) {
|
||||
_enabled = val;
|
||||
if (!val) {
|
||||
_stopStreams();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
} else {
|
||||
_startStreams();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config API (used by Ops Center voice config panel) ─────────────
|
||||
|
||||
function getConfig() {
|
||||
return JSON.parse(JSON.stringify(_config));
|
||||
}
|
||||
|
||||
function setConfig(cfg) {
|
||||
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
|
||||
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
|
||||
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||
_normalizeConfig();
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||
// Restart streams to apply per-stream toggle changes
|
||||
_stopStreams();
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
function getAvailableVoices() {
|
||||
return new Promise(resolve => {
|
||||
if (!window.speechSynthesis) { resolve([]); return; }
|
||||
let voices = speechSynthesis.getVoices();
|
||||
if (voices.length > 0) { resolve(voices); return; }
|
||||
speechSynthesis.onvoiceschanged = () => {
|
||||
resolve(speechSynthesis.getVoices());
|
||||
};
|
||||
// Timeout fallback
|
||||
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
|
||||
});
|
||||
}
|
||||
|
||||
function testVoice(text) {
|
||||
if (!_isSpeechSupported()) {
|
||||
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Make the test immediate and recover from a paused/stalled synthesis engine.
|
||||
try {
|
||||
speechSynthesis.getVoices();
|
||||
if (speechSynthesis.paused) speechSynthesis.resume();
|
||||
speechSynthesis.cancel();
|
||||
} catch (_) {}
|
||||
|
||||
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
|
||||
let started = false;
|
||||
utt.onstart = () => { started = true; };
|
||||
utt.onerror = () => {
|
||||
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
|
||||
};
|
||||
speechSynthesis.speak(utt);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
|
||||
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
})();
|
||||
|
||||
window.VoiceAlerts = VoiceAlerts;
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
/* Signal Fingerprinting — RF baseline recorder + anomaly comparator */
|
||||
const Fingerprint = (function () {
|
||||
'use strict';
|
||||
|
||||
let _active = false;
|
||||
let _recording = false;
|
||||
let _scannerSource = null;
|
||||
let _pendingObs = [];
|
||||
let _flushTimer = null;
|
||||
let _currentTab = 'record';
|
||||
let _chartInstance = null;
|
||||
let _ownedScanner = false;
|
||||
let _obsCount = 0;
|
||||
|
||||
function _flushObservations() {
|
||||
if (!_recording || _pendingObs.length === 0) return;
|
||||
const batch = _pendingObs.splice(0);
|
||||
fetch('/fingerprint/observation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ observations: batch }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function _startScannerStream() {
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
_scannerSource = new EventSource('/listening/scanner/stream');
|
||||
_scannerSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
// Only collect meaningful signal events (signal_found has SNR)
|
||||
if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return;
|
||||
|
||||
const freq = d.frequency ?? d.freq_mhz ?? null;
|
||||
if (freq === null) return;
|
||||
|
||||
// Prefer SNR (dB) from signal_found events; fall back to level for scan_update
|
||||
let power = null;
|
||||
if (d.snr !== undefined && d.snr !== null) {
|
||||
power = d.snr;
|
||||
} else if (d.level !== undefined && d.level !== null) {
|
||||
// level is RMS audio — skip scan_update noise floor readings
|
||||
if (d.type === 'signal_found') {
|
||||
power = d.level;
|
||||
} else {
|
||||
return; // scan_update with no SNR — skip
|
||||
}
|
||||
} else if (d.power_dbm !== undefined) {
|
||||
power = d.power_dbm;
|
||||
}
|
||||
|
||||
if (power === null) return;
|
||||
|
||||
if (_recording) {
|
||||
_pendingObs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) });
|
||||
_obsCount++;
|
||||
_updateObsCounter();
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
function _updateObsCounter() {
|
||||
const el = document.getElementById('fpObsCount');
|
||||
if (el) el.textContent = _obsCount;
|
||||
}
|
||||
|
||||
function _setStatus(msg) {
|
||||
const el = document.getElementById('fpRecordStatus');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Scanner lifecycle (standalone control) ─────────────────────────
|
||||
|
||||
async function _checkScannerStatus() {
|
||||
try {
|
||||
const r = await fetch('/listening/scanner/status');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
return !!d.running;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function _updateScannerStatusUI() {
|
||||
const running = await _checkScannerStatus();
|
||||
const dotEl = document.getElementById('fpScannerDot');
|
||||
const textEl = document.getElementById('fpScannerStatusText');
|
||||
const startB = document.getElementById('fpScannerStartBtn');
|
||||
const stopB = document.getElementById('fpScannerStopBtn');
|
||||
|
||||
if (dotEl) dotEl.style.background = running ? 'var(--accent-green, #00ff88)' : 'rgba(255,255,255,0.2)';
|
||||
if (textEl) textEl.textContent = running ? 'Scanner running' : 'Scanner not running';
|
||||
if (startB) startB.style.display = running ? 'none' : '';
|
||||
if (stopB) stopB.style.display = (running && _ownedScanner) ? '' : 'none';
|
||||
|
||||
// Auto-connect to stream if scanner is running
|
||||
if (running && !_scannerSource) _startScannerStream();
|
||||
}
|
||||
|
||||
async function startScanner() {
|
||||
const deviceVal = document.getElementById('fpDevice')?.value || 'rtlsdr:0';
|
||||
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
|
||||
const startB = document.getElementById('fpScannerStartBtn');
|
||||
if (startB) { startB.disabled = true; startB.textContent = 'Starting…'; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/listening/scanner/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_freq: 24, end_freq: 1700, sdr_type: sdrType, device: parseInt(idxStr) || 0 }),
|
||||
});
|
||||
if (res.ok) {
|
||||
_ownedScanner = true;
|
||||
_startScannerStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (startB) { startB.disabled = false; startB.textContent = 'Start Scanner'; }
|
||||
await _updateScannerStatusUI();
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (!_ownedScanner) return;
|
||||
try {
|
||||
await fetch('/listening/scanner/stop', { method: 'POST' });
|
||||
} catch (_) {}
|
||||
_ownedScanner = false;
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
await _updateScannerStatusUI();
|
||||
}
|
||||
|
||||
// ── Recording ──────────────────────────────────────────────────────
|
||||
|
||||
async function startRecording() {
|
||||
// Check scanner is running first
|
||||
const running = await _checkScannerStatus();
|
||||
if (!running) {
|
||||
_setStatus('Scanner not running — start it first (Step 2)');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('fpSessionName')?.value.trim() || 'Session ' + new Date().toLocaleString();
|
||||
const location = document.getElementById('fpSessionLocation')?.value.trim() || null;
|
||||
try {
|
||||
const res = await fetch('/fingerprint/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, location }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Start failed');
|
||||
_recording = true;
|
||||
_pendingObs = [];
|
||||
_obsCount = 0;
|
||||
_updateObsCounter();
|
||||
_flushTimer = setInterval(_flushObservations, 5000);
|
||||
if (!_scannerSource) _startScannerStream();
|
||||
const startBtn = document.getElementById('fpStartBtn');
|
||||
const stopBtn = document.getElementById('fpStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = '';
|
||||
_setStatus('Recording… session #' + data.session_id);
|
||||
} catch (e) {
|
||||
_setStatus('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
_recording = false;
|
||||
_flushObservations();
|
||||
if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; }
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
try {
|
||||
const res = await fetch('/fingerprint/stop', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
_setStatus(`Saved: ${data.bands_recorded} bands recorded (${_obsCount} observations)`);
|
||||
} catch (e) {
|
||||
_setStatus('Error saving: ' + e.message);
|
||||
}
|
||||
const startBtn = document.getElementById('fpStartBtn');
|
||||
const stopBtn = document.getElementById('fpStopBtn');
|
||||
if (startBtn) startBtn.style.display = '';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
async function _loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/fingerprint/list');
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById('fpBaselineSelect');
|
||||
if (!sel) return;
|
||||
const sessions = (data.sessions || []).filter(s => s.finalized_at);
|
||||
sel.innerHTML = sessions.length
|
||||
? sessions.map(s => `<option value="${s.id}">[${s.id}] ${s.name} (${s.band_count || 0} bands)</option>`).join('')
|
||||
: '<option value="">No saved baselines</option>';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Compare ────────────────────────────────────────────────────────
|
||||
|
||||
async function compareNow() {
|
||||
const baselineId = document.getElementById('fpBaselineSelect')?.value;
|
||||
if (!baselineId) return;
|
||||
|
||||
// Check scanner is running
|
||||
const running = await _checkScannerStatus();
|
||||
if (!running) {
|
||||
const statusEl = document.getElementById('fpCompareStatus');
|
||||
if (statusEl) statusEl.textContent = 'Scanner not running — start it first';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('fpCompareStatus');
|
||||
const compareBtn = document.querySelector('#fpComparePanel .run-btn');
|
||||
if (statusEl) statusEl.textContent = 'Collecting observations…';
|
||||
if (compareBtn) { compareBtn.disabled = true; compareBtn.textContent = 'Scanning…'; }
|
||||
|
||||
// Collect live observations for ~3 seconds
|
||||
const obs = [];
|
||||
const tmpSrc = new EventSource('/listening/scanner/stream');
|
||||
const deadline = Date.now() + 3000;
|
||||
|
||||
await new Promise(resolve => {
|
||||
tmpSrc.onmessage = (ev) => {
|
||||
if (Date.now() > deadline) { tmpSrc.close(); resolve(); return; }
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return;
|
||||
const freq = d.frequency ?? d.freq_mhz ?? null;
|
||||
let power = null;
|
||||
if (d.snr !== undefined && d.snr !== null) power = d.snr;
|
||||
else if (d.type === 'signal_found' && d.level !== undefined) power = d.level;
|
||||
else if (d.power_dbm !== undefined) power = d.power_dbm;
|
||||
if (freq !== null && power !== null) obs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) });
|
||||
if (statusEl) statusEl.textContent = `Collecting… ${obs.length} observations`;
|
||||
} catch (_) {}
|
||||
};
|
||||
tmpSrc.onerror = () => { tmpSrc.close(); resolve(); };
|
||||
setTimeout(() => { tmpSrc.close(); resolve(); }, 3500);
|
||||
});
|
||||
|
||||
if (statusEl) statusEl.textContent = `Comparing ${obs.length} observations against baseline…`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/fingerprint/compare', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseline_id: parseInt(baselineId), observations: obs }),
|
||||
});
|
||||
const data = await res.json();
|
||||
_renderAnomalies(data.anomalies || []);
|
||||
_renderChart(data.baseline_bands || [], data.anomalies || []);
|
||||
if (statusEl) statusEl.textContent = `Done — ${obs.length} observations, ${(data.anomalies || []).length} anomalies`;
|
||||
} catch (e) {
|
||||
console.error('Compare failed:', e);
|
||||
if (statusEl) statusEl.textContent = 'Compare failed: ' + e.message;
|
||||
}
|
||||
|
||||
if (compareBtn) { compareBtn.disabled = false; compareBtn.textContent = 'Compare Now'; }
|
||||
}
|
||||
|
||||
function _renderAnomalies(anomalies) {
|
||||
const panel = document.getElementById('fpAnomalyList');
|
||||
const items = document.getElementById('fpAnomalyItems');
|
||||
if (!panel || !items) return;
|
||||
|
||||
if (anomalies.length === 0) {
|
||||
items.innerHTML = '<div style="font-size:11px; color:var(--text-dim); padding:8px;">No significant anomalies detected.</div>';
|
||||
panel.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
items.innerHTML = anomalies.map(a => {
|
||||
const z = a.z_score !== null ? Math.abs(a.z_score) : 999;
|
||||
let cls = 'severity-warn', badge = 'POWER';
|
||||
if (a.anomaly_type === 'new') { cls = 'severity-new'; badge = 'NEW'; }
|
||||
else if (a.anomaly_type === 'missing') { cls = 'severity-warn'; badge = 'MISSING'; }
|
||||
else if (z >= 3) { cls = 'severity-alert'; }
|
||||
|
||||
const zText = a.z_score !== null ? `z=${a.z_score.toFixed(1)}` : '';
|
||||
const powerText = a.current_power !== null ? `${a.current_power.toFixed(1)} dBm` : 'absent';
|
||||
const baseText = a.baseline_mean !== null ? `baseline: ${a.baseline_mean.toFixed(1)} dBm` : '';
|
||||
|
||||
return `<div class="fp-anomaly-item ${cls}">
|
||||
<div style="display:flex; align-items:center; gap:6px;">
|
||||
<span class="fp-anomaly-band">${a.band_label}</span>
|
||||
<span class="fp-anomaly-type-badge" style="background:rgba(255,255,255,0.1);">${badge}</span>
|
||||
${z >= 3 ? '<span style="color:#ef4444; font-size:9px; font-weight:700;">ALERT</span>' : ''}
|
||||
</div>
|
||||
<div style="color:var(--text-secondary);">${powerText} ${baseText} ${zText}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
panel.style.display = 'block';
|
||||
|
||||
// Voice alert for high-severity anomalies
|
||||
const highZ = anomalies.find(a => (a.z_score !== null && Math.abs(a.z_score) >= 3) || a.anomaly_type === 'new');
|
||||
if (highZ && window.VoiceAlerts) {
|
||||
VoiceAlerts.speak(`RF anomaly detected: ${highZ.band_label} — ${highZ.anomaly_type}`, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderChart(baselineBands, anomalies) {
|
||||
const canvas = document.getElementById('fpChartCanvas');
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
|
||||
const anomalyMap = {};
|
||||
anomalies.forEach(a => { anomalyMap[a.band_center_mhz] = a; });
|
||||
|
||||
const bands = baselineBands.slice(0, 40);
|
||||
const labels = bands.map(b => b.band_center_mhz.toFixed(1));
|
||||
const means = bands.map(b => b.mean_dbm);
|
||||
const currentPowers = bands.map(b => {
|
||||
const a = anomalyMap[b.band_center_mhz];
|
||||
return a ? a.current_power : b.mean_dbm;
|
||||
});
|
||||
const barColors = bands.map(b => {
|
||||
const a = anomalyMap[b.band_center_mhz];
|
||||
if (!a) return 'rgba(74,163,255,0.6)';
|
||||
if (a.anomaly_type === 'new') return 'rgba(168,85,247,0.8)';
|
||||
if (a.z_score !== null && Math.abs(a.z_score) >= 3) return 'rgba(239,68,68,0.8)';
|
||||
return 'rgba(251,191,36,0.7)';
|
||||
});
|
||||
|
||||
if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
|
||||
|
||||
_chartInstance = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Baseline Mean', data: means, backgroundColor: 'rgba(74,163,255,0.3)', borderColor: 'rgba(74,163,255,0.8)', borderWidth: 1 },
|
||||
{ label: 'Current', data: currentPowers, backgroundColor: barColors, borderColor: barColors, borderWidth: 1 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#aaa', font: { size: 10 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#666', font: { size: 9 }, maxRotation: 90 }, grid: { color: 'rgba(255,255,255,0.05)' } },
|
||||
y: { ticks: { color: '#666', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: 'Power (dBm)', color: '#666' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tab) {
|
||||
_currentTab = tab;
|
||||
const recordPanel = document.getElementById('fpRecordPanel');
|
||||
const comparePanel = document.getElementById('fpComparePanel');
|
||||
if (recordPanel) recordPanel.style.display = tab === 'record' ? '' : 'none';
|
||||
if (comparePanel) comparePanel.style.display = tab === 'compare' ? '' : 'none';
|
||||
document.querySelectorAll('.fp-tab-btn').forEach(b => b.classList.remove('active'));
|
||||
const activeBtn = tab === 'record'
|
||||
? document.getElementById('fpTabRecord')
|
||||
: document.getElementById('fpTabCompare');
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
const hintEl = document.getElementById('fpTabHint');
|
||||
if (hintEl) hintEl.innerHTML = TAB_HINTS[tab] || '';
|
||||
if (tab === 'compare') _loadSessions();
|
||||
}
|
||||
|
||||
function _loadDevices() {
|
||||
const sel = document.getElementById('fpDevice');
|
||||
if (!sel) return;
|
||||
fetch('/devices').then(r => r.json()).then(devices => {
|
||||
if (!devices || devices.length === 0) {
|
||||
sel.innerHTML = '<option value="">No SDR devices detected</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = devices.map(d => {
|
||||
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
|
||||
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
|
||||
}).join('');
|
||||
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
|
||||
}
|
||||
|
||||
const TAB_HINTS = {
|
||||
record: 'Record a <strong style="color:var(--text-secondary);">baseline</strong> in a known-clean RF environment, then use <strong style="color:var(--text-secondary);">Compare</strong> later to detect new or anomalous signals.',
|
||||
compare: 'Select a saved baseline and click <strong style="color:var(--text-secondary);">Compare Now</strong> to scan for deviations. Anomalies are flagged by statistical z-score.',
|
||||
};
|
||||
|
||||
function init() {
|
||||
_active = true;
|
||||
_loadDevices();
|
||||
_loadSessions();
|
||||
_updateScannerStatusUI();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
_active = false;
|
||||
if (_recording) stopRecording();
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
|
||||
if (_ownedScanner) stopScanner();
|
||||
}
|
||||
|
||||
return { init, destroy, showTab, startRecording, stopRecording, compareNow, startScanner, stopScanner };
|
||||
})();
|
||||
|
||||
window.Fingerprint = Fingerprint;
|
||||
@@ -4,11 +4,14 @@
|
||||
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
||||
*/
|
||||
|
||||
const GPS = (function() {
|
||||
let connected = false;
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
let skyPollTimer = null;
|
||||
const GPS = (function() {
|
||||
let connected = false;
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
let skyPollTimer = null;
|
||||
let themeObserver = null;
|
||||
let skyRenderer = null;
|
||||
let skyRendererInitAttempted = false;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
@@ -20,20 +23,45 @@ const GPS = (function() {
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
function init() {
|
||||
drawEmptySkyView();
|
||||
connect();
|
||||
|
||||
// Redraw sky view when theme changes
|
||||
const observer = new MutationObserver(() => {
|
||||
if (lastSky) {
|
||||
drawSkyView(lastSky.satellites || []);
|
||||
} else {
|
||||
drawEmptySkyView();
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
}
|
||||
function init() {
|
||||
initSkyRenderer();
|
||||
drawEmptySkyView();
|
||||
if (!connected) connect();
|
||||
|
||||
// Redraw sky view when theme changes
|
||||
if (!themeObserver) {
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
|
||||
skyRenderer.requestRender();
|
||||
}
|
||||
if (lastSky) {
|
||||
drawSkyView(lastSky.satellites || []);
|
||||
} else {
|
||||
drawEmptySkyView();
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
}
|
||||
|
||||
if (lastPosition) updatePositionUI(lastPosition);
|
||||
if (lastSky) updateSkyUI(lastSky);
|
||||
}
|
||||
|
||||
function initSkyRenderer() {
|
||||
if (skyRendererInitAttempted) return;
|
||||
skyRendererInitAttempted = true;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const overlay = document.getElementById('gpsSkyOverlay');
|
||||
try {
|
||||
skyRenderer = createWebGlSkyRenderer(canvas, overlay);
|
||||
} catch (err) {
|
||||
skyRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
updateConnectionUI(false, false, 'connecting');
|
||||
@@ -252,139 +280,745 @@ const GPS = (function() {
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Sky View Polar Plot
|
||||
// ========================
|
||||
|
||||
function drawEmptySkyView() {
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
drawSkyViewBase(canvas);
|
||||
}
|
||||
|
||||
function drawSkyView(satellites) {
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
drawSkyViewBase(canvas);
|
||||
|
||||
// Plot satellites
|
||||
satellites.forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const elRad = (90 - sat.elevation) / 90;
|
||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
||||
const px = cx + r * elRad * Math.cos(azRad);
|
||||
const py = cy + r * elRad * Math.sin(azRad);
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||
const dotSize = sat.used ? 6 : 4;
|
||||
|
||||
// Draw dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||
if (sat.used) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// PRN label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '8px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||
|
||||
// SNR value
|
||||
if (sat.snr != null) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '7px Roboto Condensed, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawSkyViewBase(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
|
||||
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
|
||||
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Elevation rings (0, 30, 60, 90)
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 0.5;
|
||||
[90, 60, 30].forEach(el => {
|
||||
const gr = r * (1 - el / 90);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Label
|
||||
ctx.fillStyle = dimColor;
|
||||
ctx.font = '9px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||
});
|
||||
|
||||
// Horizon circle
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Cardinal directions
|
||||
ctx.fillStyle = secondaryColor;
|
||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('N', cx, cy - r - 12);
|
||||
ctx.fillText('S', cx, cy + r + 12);
|
||||
ctx.fillText('E', cx + r + 12, cy);
|
||||
ctx.fillText('W', cx - r - 12, cy);
|
||||
|
||||
// Crosshairs
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx, cy + r);
|
||||
ctx.moveTo(cx - r, cy);
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.stroke();
|
||||
|
||||
// Zenith dot
|
||||
ctx.fillStyle = dimColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
// ========================
|
||||
// Sky View Globe (WebGL with 2D fallback)
|
||||
// ========================
|
||||
|
||||
function drawEmptySkyView() {
|
||||
if (!skyRendererInitAttempted) {
|
||||
initSkyRenderer();
|
||||
}
|
||||
|
||||
if (skyRenderer) {
|
||||
skyRenderer.setSatellites([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
drawSkyViewBase2D(canvas);
|
||||
}
|
||||
|
||||
function drawSkyView(satellites) {
|
||||
if (!skyRendererInitAttempted) {
|
||||
initSkyRenderer();
|
||||
}
|
||||
|
||||
const sats = Array.isArray(satellites) ? satellites : [];
|
||||
|
||||
if (skyRenderer) {
|
||||
skyRenderer.setSatellites(sats);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
drawSkyViewBase2D(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
sats.forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const elRad = (90 - sat.elevation) / 90;
|
||||
const azRad = (sat.azimuth - 90) * Math.PI / 180;
|
||||
const px = cx + r * elRad * Math.cos(azRad);
|
||||
const py = cy + r * elRad * Math.sin(azRad);
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||
const dotSize = sat.used ? 6 : 4;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||
if (sat.used) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '8px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||
|
||||
if (sat.snr != null) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '7px Roboto Condensed, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawSkyViewBase2D(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
|
||||
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
|
||||
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 0.5;
|
||||
[90, 60, 30].forEach(el => {
|
||||
const gr = r * (1 - el / 90);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = dimColor;
|
||||
ctx.font = '9px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = secondaryColor;
|
||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('N', cx, cy - r - 12);
|
||||
ctx.fillText('S', cx, cy + r + 12);
|
||||
ctx.fillText('E', cx + r + 12, cy);
|
||||
ctx.fillText('W', cx - r - 12, cy);
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx, cy + r);
|
||||
ctx.moveTo(cx - r, cy);
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = dimColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function createWebGlSkyRenderer(canvas, overlay) {
|
||||
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
|
||||
if (!gl) return null;
|
||||
|
||||
const lineProgram = createProgram(
|
||||
gl,
|
||||
[
|
||||
'attribute vec3 aPosition;',
|
||||
'uniform mat4 uMVP;',
|
||||
'void main(void) {',
|
||||
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||
'}',
|
||||
].join('\n'),
|
||||
[
|
||||
'precision mediump float;',
|
||||
'uniform vec4 uColor;',
|
||||
'void main(void) {',
|
||||
' gl_FragColor = uColor;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const pointProgram = createProgram(
|
||||
gl,
|
||||
[
|
||||
'attribute vec3 aPosition;',
|
||||
'attribute vec4 aColor;',
|
||||
'attribute float aSize;',
|
||||
'attribute float aUsed;',
|
||||
'uniform mat4 uMVP;',
|
||||
'uniform float uDevicePixelRatio;',
|
||||
'uniform vec3 uCameraDir;',
|
||||
'varying vec4 vColor;',
|
||||
'varying float vUsed;',
|
||||
'varying float vFacing;',
|
||||
'void main(void) {',
|
||||
' vec3 normPos = normalize(aPosition);',
|
||||
' vFacing = dot(normPos, normalize(uCameraDir));',
|
||||
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||
' gl_PointSize = aSize * uDevicePixelRatio;',
|
||||
' vColor = aColor;',
|
||||
' vUsed = aUsed;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
[
|
||||
'precision mediump float;',
|
||||
'varying vec4 vColor;',
|
||||
'varying float vUsed;',
|
||||
'varying float vFacing;',
|
||||
'void main(void) {',
|
||||
' if (vFacing <= 0.0) discard;',
|
||||
' vec2 c = gl_PointCoord * 2.0 - 1.0;',
|
||||
' float d = dot(c, c);',
|
||||
' if (d > 1.0) discard;',
|
||||
' if (vUsed < 0.5 && d < 0.45) discard;',
|
||||
' float edge = smoothstep(1.0, 0.75, d);',
|
||||
' gl_FragColor = vec4(vColor.rgb, vColor.a * edge);',
|
||||
'}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
if (!lineProgram || !pointProgram) return null;
|
||||
|
||||
const lineLoc = {
|
||||
position: gl.getAttribLocation(lineProgram, 'aPosition'),
|
||||
mvp: gl.getUniformLocation(lineProgram, 'uMVP'),
|
||||
color: gl.getUniformLocation(lineProgram, 'uColor'),
|
||||
};
|
||||
|
||||
const pointLoc = {
|
||||
position: gl.getAttribLocation(pointProgram, 'aPosition'),
|
||||
color: gl.getAttribLocation(pointProgram, 'aColor'),
|
||||
size: gl.getAttribLocation(pointProgram, 'aSize'),
|
||||
used: gl.getAttribLocation(pointProgram, 'aUsed'),
|
||||
mvp: gl.getUniformLocation(pointProgram, 'uMVP'),
|
||||
dpr: gl.getUniformLocation(pointProgram, 'uDevicePixelRatio'),
|
||||
cameraDir: gl.getUniformLocation(pointProgram, 'uCameraDir'),
|
||||
};
|
||||
|
||||
const gridVertices = buildSkyGridVertices();
|
||||
const horizonVertices = buildSkyRingVertices(0, 4);
|
||||
|
||||
const gridBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, gridVertices, gl.STATIC_DRAW);
|
||||
|
||||
const horizonBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, horizonVertices, gl.STATIC_DRAW);
|
||||
|
||||
const satPosBuffer = gl.createBuffer();
|
||||
const satColorBuffer = gl.createBuffer();
|
||||
const satSizeBuffer = gl.createBuffer();
|
||||
const satUsedBuffer = gl.createBuffer();
|
||||
|
||||
let satCount = 0;
|
||||
let satLabels = [];
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let devicePixelRatio = 1;
|
||||
let mvpMatrix = identityMat4();
|
||||
let cameraDir = [0, 1, 0];
|
||||
let yaw = 0.8;
|
||||
let pitch = 0.6;
|
||||
let distance = 2.7;
|
||||
let rafId = null;
|
||||
let destroyed = false;
|
||||
let activePointerId = null;
|
||||
let lastPointerX = 0;
|
||||
let lastPointerY = 0;
|
||||
|
||||
const resizeObserver = (typeof ResizeObserver !== 'undefined')
|
||||
? new ResizeObserver(() => {
|
||||
requestRender();
|
||||
})
|
||||
: null;
|
||||
if (resizeObserver) resizeObserver.observe(canvas);
|
||||
|
||||
canvas.addEventListener('pointerdown', onPointerDown);
|
||||
canvas.addEventListener('pointermove', onPointerMove);
|
||||
canvas.addEventListener('pointerup', onPointerUp);
|
||||
canvas.addEventListener('pointercancel', onPointerUp);
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
requestRender();
|
||||
|
||||
function onPointerDown(evt) {
|
||||
activePointerId = evt.pointerId;
|
||||
lastPointerX = evt.clientX;
|
||||
lastPointerY = evt.clientY;
|
||||
if (canvas.setPointerCapture) canvas.setPointerCapture(evt.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(evt) {
|
||||
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||
|
||||
const dx = evt.clientX - lastPointerX;
|
||||
const dy = evt.clientY - lastPointerY;
|
||||
lastPointerX = evt.clientX;
|
||||
lastPointerY = evt.clientY;
|
||||
|
||||
yaw += dx * 0.01;
|
||||
pitch += dy * 0.01;
|
||||
pitch = Math.max(0.1, Math.min(1.45, pitch));
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function onPointerUp(evt) {
|
||||
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||
if (canvas.releasePointerCapture) {
|
||||
try {
|
||||
canvas.releasePointerCapture(evt.pointerId);
|
||||
} catch (_) {}
|
||||
}
|
||||
activePointerId = null;
|
||||
}
|
||||
|
||||
function onWheel(evt) {
|
||||
evt.preventDefault();
|
||||
distance += evt.deltaY * 0.002;
|
||||
distance = Math.max(2.0, Math.min(5.0, distance));
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function setSatellites(satellites) {
|
||||
const positions = [];
|
||||
const colors = [];
|
||||
const sizes = [];
|
||||
const usedFlags = [];
|
||||
const labels = [];
|
||||
|
||||
(satellites || []).forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const xyz = skyToCartesian(sat.azimuth, sat.elevation);
|
||||
const hex = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||
const rgb = hexToRgb01(hex);
|
||||
|
||||
positions.push(xyz[0], xyz[1], xyz[2]);
|
||||
colors.push(rgb[0], rgb[1], rgb[2], sat.used ? 1 : 0.85);
|
||||
sizes.push(sat.used ? 8 : 7);
|
||||
usedFlags.push(sat.used ? 1 : 0);
|
||||
|
||||
labels.push({
|
||||
text: String(sat.prn),
|
||||
point: xyz,
|
||||
color: hex,
|
||||
used: !!sat.used,
|
||||
});
|
||||
});
|
||||
|
||||
satLabels = labels;
|
||||
satCount = positions.length / 3;
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.DYNAMIC_DRAW);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(usedFlags), gl.DYNAMIC_DRAW);
|
||||
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
if (destroyed || rafId != null) return;
|
||||
rafId = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function render() {
|
||||
rafId = null;
|
||||
if (destroyed) return;
|
||||
|
||||
resizeCanvas();
|
||||
updateCameraMatrices();
|
||||
|
||||
const palette = getThemePalette();
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(palette.bg[0], palette.bg[1], palette.bg[2], 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
gl.useProgram(lineProgram);
|
||||
gl.uniformMatrix4fv(lineLoc.mvp, false, mvpMatrix);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||
gl.enableVertexAttribArray(lineLoc.position);
|
||||
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform4fv(lineLoc.color, palette.grid);
|
||||
gl.drawArrays(gl.LINES, 0, gridVertices.length / 3);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform4fv(lineLoc.color, palette.horizon);
|
||||
gl.drawArrays(gl.LINES, 0, horizonVertices.length / 3);
|
||||
|
||||
if (satCount > 0) {
|
||||
gl.useProgram(pointProgram);
|
||||
gl.uniformMatrix4fv(pointLoc.mvp, false, mvpMatrix);
|
||||
gl.uniform1f(pointLoc.dpr, devicePixelRatio);
|
||||
gl.uniform3fv(pointLoc.cameraDir, new Float32Array(cameraDir));
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.position);
|
||||
gl.vertexAttribPointer(pointLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.color);
|
||||
gl.vertexAttribPointer(pointLoc.color, 4, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.size);
|
||||
gl.vertexAttribPointer(pointLoc.size, 1, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||
gl.enableVertexAttribArray(pointLoc.used);
|
||||
gl.vertexAttribPointer(pointLoc.used, 1, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.drawArrays(gl.POINTS, 0, satCount);
|
||||
}
|
||||
|
||||
drawOverlayLabels();
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
|
||||
cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
|
||||
devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
const renderWidth = Math.floor(cssWidth * devicePixelRatio);
|
||||
const renderHeight = Math.floor(cssHeight * devicePixelRatio);
|
||||
if (canvas.width !== renderWidth || canvas.height !== renderHeight) {
|
||||
canvas.width = renderWidth;
|
||||
canvas.height = renderHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCameraMatrices() {
|
||||
const cosPitch = Math.cos(pitch);
|
||||
const eye = [
|
||||
distance * Math.sin(yaw) * cosPitch,
|
||||
distance * Math.sin(pitch),
|
||||
distance * Math.cos(yaw) * cosPitch,
|
||||
];
|
||||
|
||||
const eyeLen = Math.hypot(eye[0], eye[1], eye[2]) || 1;
|
||||
cameraDir = [eye[0] / eyeLen, eye[1] / eyeLen, eye[2] / eyeLen];
|
||||
|
||||
const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0]);
|
||||
const proj = mat4Perspective(degToRad(48), Math.max(cssWidth / cssHeight, 0.01), 0.1, 20);
|
||||
mvpMatrix = mat4Multiply(proj, view);
|
||||
}
|
||||
|
||||
function drawOverlayLabels() {
|
||||
if (!overlay) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const cardinals = [
|
||||
{ text: 'N', point: [0, 0, 1] },
|
||||
{ text: 'E', point: [1, 0, 0] },
|
||||
{ text: 'S', point: [0, 0, -1] },
|
||||
{ text: 'W', point: [-1, 0, 0] },
|
||||
{ text: 'Z', point: [0, 1, 0] },
|
||||
];
|
||||
|
||||
cardinals.forEach(entry => {
|
||||
addLabel(fragment, entry.text, entry.point, 'gps-sky-label gps-sky-label-cardinal');
|
||||
});
|
||||
|
||||
satLabels.forEach(sat => {
|
||||
const cls = 'gps-sky-label gps-sky-label-sat' + (sat.used ? '' : ' unused');
|
||||
addLabel(fragment, sat.text, sat.point, cls, sat.color);
|
||||
});
|
||||
|
||||
overlay.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addLabel(fragment, text, point, className, color) {
|
||||
const facing = point[0] * cameraDir[0] + point[1] * cameraDir[1] + point[2] * cameraDir[2];
|
||||
if (facing <= 0.02) return;
|
||||
|
||||
const projected = projectPoint(point, mvpMatrix, cssWidth, cssHeight);
|
||||
if (!projected) return;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = className;
|
||||
label.textContent = text;
|
||||
label.style.left = projected.x.toFixed(1) + 'px';
|
||||
label.style.top = projected.y.toFixed(1) + 'px';
|
||||
if (color) label.style.color = color;
|
||||
fragment.appendChild(label);
|
||||
}
|
||||
|
||||
function getThemePalette() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = parseCssColor(cs.getPropertyValue('--bg-card').trim(), '#0d1117');
|
||||
const grid = parseCssColor(cs.getPropertyValue('--border-color').trim(), '#3a4254');
|
||||
const accent = parseCssColor(cs.getPropertyValue('--accent-cyan').trim(), '#4aa3ff');
|
||||
|
||||
return {
|
||||
bg: bg,
|
||||
grid: [grid[0], grid[1], grid[2], 0.42],
|
||||
horizon: [accent[0], accent[1], accent[2], 0.56],
|
||||
};
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
canvas.removeEventListener('pointercancel', onPointerUp);
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
if (resizeObserver) {
|
||||
try {
|
||||
resizeObserver.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (overlay) overlay.replaceChildren();
|
||||
}
|
||||
|
||||
return {
|
||||
setSatellites: setSatellites,
|
||||
requestRender: requestRender,
|
||||
destroy: destroy,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkyGridVertices() {
|
||||
const vertices = [];
|
||||
|
||||
[15, 30, 45, 60, 75].forEach(el => {
|
||||
appendLineStrip(vertices, buildRingPoints(el, 6));
|
||||
});
|
||||
|
||||
for (let az = 0; az < 360; az += 30) {
|
||||
appendLineStrip(vertices, buildMeridianPoints(az, 5));
|
||||
}
|
||||
|
||||
return new Float32Array(vertices);
|
||||
}
|
||||
|
||||
function buildSkyRingVertices(elevation, stepAz) {
|
||||
const vertices = [];
|
||||
appendLineStrip(vertices, buildRingPoints(elevation, stepAz));
|
||||
return new Float32Array(vertices);
|
||||
}
|
||||
|
||||
function buildRingPoints(elevation, stepAz) {
|
||||
const points = [];
|
||||
for (let az = 0; az <= 360; az += stepAz) {
|
||||
points.push(skyToCartesian(az, elevation));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function buildMeridianPoints(azimuth, stepEl) {
|
||||
const points = [];
|
||||
for (let el = 0; el <= 90; el += stepEl) {
|
||||
points.push(skyToCartesian(azimuth, el));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function appendLineStrip(target, points) {
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const a = points[i - 1];
|
||||
const b = points[i];
|
||||
target.push(a[0], a[1], a[2], b[0], b[1], b[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function skyToCartesian(azimuthDeg, elevationDeg) {
|
||||
const az = degToRad(azimuthDeg);
|
||||
const el = degToRad(elevationDeg);
|
||||
const cosEl = Math.cos(el);
|
||||
return [
|
||||
cosEl * Math.sin(az),
|
||||
Math.sin(el),
|
||||
cosEl * Math.cos(az),
|
||||
];
|
||||
}
|
||||
|
||||
function degToRad(deg) {
|
||||
return deg * Math.PI / 180;
|
||||
}
|
||||
|
||||
function createProgram(gl, vertexSource, fragmentSource) {
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
||||
if (!vertexShader || !fragmentShader) return null;
|
||||
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.warn('WebGL program link failed:', gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
function compileShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.warn('WebGL shader compile failed:', gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
function identityMat4() {
|
||||
return new Float32Array([
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
]);
|
||||
}
|
||||
|
||||
function mat4Perspective(fovy, aspect, near, far) {
|
||||
const f = 1 / Math.tan(fovy / 2);
|
||||
const nf = 1 / (near - far);
|
||||
|
||||
return new Float32Array([
|
||||
f / aspect, 0, 0, 0,
|
||||
0, f, 0, 0,
|
||||
0, 0, (far + near) * nf, -1,
|
||||
0, 0, (2 * far * near) * nf, 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function mat4LookAt(eye, center, up) {
|
||||
const zx = eye[0] - center[0];
|
||||
const zy = eye[1] - center[1];
|
||||
const zz = eye[2] - center[2];
|
||||
const zLen = Math.hypot(zx, zy, zz) || 1;
|
||||
const znx = zx / zLen;
|
||||
const zny = zy / zLen;
|
||||
const znz = zz / zLen;
|
||||
|
||||
const xx = up[1] * znz - up[2] * zny;
|
||||
const xy = up[2] * znx - up[0] * znz;
|
||||
const xz = up[0] * zny - up[1] * znx;
|
||||
const xLen = Math.hypot(xx, xy, xz) || 1;
|
||||
const xnx = xx / xLen;
|
||||
const xny = xy / xLen;
|
||||
const xnz = xz / xLen;
|
||||
|
||||
const ynx = zny * xnz - znz * xny;
|
||||
const yny = znz * xnx - znx * xnz;
|
||||
const ynz = znx * xny - zny * xnx;
|
||||
|
||||
return new Float32Array([
|
||||
xnx, ynx, znx, 0,
|
||||
xny, yny, zny, 0,
|
||||
xnz, ynz, znz, 0,
|
||||
-(xnx * eye[0] + xny * eye[1] + xnz * eye[2]),
|
||||
-(ynx * eye[0] + yny * eye[1] + ynz * eye[2]),
|
||||
-(znx * eye[0] + zny * eye[1] + znz * eye[2]),
|
||||
1,
|
||||
]);
|
||||
}
|
||||
|
||||
function mat4Multiply(a, b) {
|
||||
const out = new Float32Array(16);
|
||||
for (let col = 0; col < 4; col += 1) {
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
out[col * 4 + row] =
|
||||
a[row] * b[col * 4] +
|
||||
a[4 + row] * b[col * 4 + 1] +
|
||||
a[8 + row] * b[col * 4 + 2] +
|
||||
a[12 + row] * b[col * 4 + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectPoint(point, matrix, width, height) {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
const z = point[2];
|
||||
|
||||
const clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
|
||||
const clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
|
||||
const clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||
if (clipW <= 0.0001) return null;
|
||||
|
||||
const ndcX = clipX / clipW;
|
||||
const ndcY = clipY / clipW;
|
||||
if (Math.abs(ndcX) > 1.2 || Math.abs(ndcY) > 1.2) return null;
|
||||
|
||||
return {
|
||||
x: (ndcX * 0.5 + 0.5) * width,
|
||||
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCssColor(raw, fallbackHex) {
|
||||
const value = (raw || '').trim();
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
return hexToRgb01(value);
|
||||
}
|
||||
|
||||
const match = value.match(/rgba?\(([^)]+)\)/i);
|
||||
if (match) {
|
||||
const parts = match[1].split(',').map(part => parseFloat(part.trim()));
|
||||
if (parts.length >= 3 && parts.every(n => Number.isFinite(n))) {
|
||||
return [parts[0] / 255, parts[1] / 255, parts[2] / 255];
|
||||
}
|
||||
}
|
||||
|
||||
return hexToRgb01(fallbackHex || '#0d1117');
|
||||
}
|
||||
|
||||
function hexToRgb01(hex) {
|
||||
let clean = (hex || '').trim().replace('#', '');
|
||||
if (clean.length === 3) {
|
||||
clean = clean.split('').map(ch => ch + ch).join('');
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(clean)) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const num = parseInt(clean, 16);
|
||||
return [
|
||||
((num >> 16) & 255) / 255,
|
||||
((num >> 8) & 255) / 255,
|
||||
(num & 255) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Signal Strength Bars
|
||||
@@ -439,10 +1073,19 @@ const GPS = (function() {
|
||||
// Cleanup
|
||||
// ========================
|
||||
|
||||
function destroy() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
}
|
||||
function destroy() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
}
|
||||
if (skyRenderer) {
|
||||
skyRenderer.destroy();
|
||||
skyRenderer = null;
|
||||
}
|
||||
skyRendererInitAttempted = false;
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
||||
/* RF Heatmap — GPS + signal strength Leaflet heatmap */
|
||||
const RFHeatmap = (function () {
|
||||
'use strict';
|
||||
|
||||
let _map = null;
|
||||
let _heatLayer = null;
|
||||
let _gpsSource = null;
|
||||
let _sigSource = null;
|
||||
let _heatPoints = [];
|
||||
let _isRecording = false;
|
||||
let _lastLat = null, _lastLng = null;
|
||||
let _minDist = 5;
|
||||
let _source = 'wifi';
|
||||
let _gpsPos = null;
|
||||
let _lastSignal = null;
|
||||
let _active = false;
|
||||
let _ownedSource = false; // true if heatmap started the source itself
|
||||
|
||||
const RSSI_RANGES = {
|
||||
wifi: { min: -90, max: -30 },
|
||||
bluetooth: { min: -100, max: -40 },
|
||||
scanner: { min: -120, max: -20 },
|
||||
};
|
||||
|
||||
function _norm(val, src) {
|
||||
const r = RSSI_RANGES[src] || RSSI_RANGES.wifi;
|
||||
return Math.max(0, Math.min(1, (val - r.min) / (r.max - r.min)));
|
||||
}
|
||||
|
||||
function _haversineM(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function _ensureLeafletHeat(cb) {
|
||||
if (window.L && L.heatLayer) { cb(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = '/static/js/vendor/leaflet-heat.js';
|
||||
s.onload = cb;
|
||||
s.onerror = () => console.warn('RF Heatmap: leaflet-heat.js failed to load');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _initMap() {
|
||||
if (_map) return;
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (!el) return;
|
||||
|
||||
// Defer map creation until container has non-zero dimensions (prevents leaflet-heat IndexSizeError)
|
||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) {
|
||||
setTimeout(_initMap, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = _getFallbackPos();
|
||||
const lat = _gpsPos ? _gpsPos.lat : (fallback ? fallback.lat : 37.7749);
|
||||
const lng = _gpsPos ? _gpsPos.lng : (fallback ? fallback.lng : -122.4194);
|
||||
|
||||
_map = L.map(el, { zoomControl: true }).setView([lat, lng], 16);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 20,
|
||||
}).addTo(_map);
|
||||
|
||||
_heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 17 }).addTo(_map);
|
||||
}
|
||||
|
||||
function _startGPS() {
|
||||
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
|
||||
_gpsSource = new EventSource('/gps/stream');
|
||||
_gpsSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.lat && d.lng && d.fix) {
|
||||
_gpsPos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) };
|
||||
_updateGpsPill(true, _gpsPos.lat, _gpsPos.lng);
|
||||
if (_map) _map.setView([_gpsPos.lat, _gpsPos.lng], _map.getZoom(), { animate: false });
|
||||
} else {
|
||||
_updateGpsPill(false);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
_gpsSource.onerror = () => _updateGpsPill(false);
|
||||
}
|
||||
|
||||
function _updateGpsPill(fix, lat, lng) {
|
||||
const pill = document.getElementById('rfhmGpsPill');
|
||||
if (!pill) return;
|
||||
if (fix && lat !== undefined) {
|
||||
pill.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||||
pill.style.color = 'var(--accent-green, #00ff88)';
|
||||
} else {
|
||||
const fallback = _getFallbackPos();
|
||||
pill.textContent = fallback ? 'No Fix (using fallback)' : 'No Fix';
|
||||
pill.style.color = fallback ? 'var(--accent-yellow, #f59e0b)' : 'var(--text-dim, #555)';
|
||||
}
|
||||
}
|
||||
|
||||
function _startSignalStream() {
|
||||
if (_sigSource) { _sigSource.close(); _sigSource = null; }
|
||||
let url;
|
||||
if (_source === 'wifi') url = '/wifi/stream';
|
||||
else if (_source === 'bluetooth') url = '/api/bluetooth/stream';
|
||||
else url = '/listening/scanner/stream';
|
||||
|
||||
_sigSource = new EventSource(url);
|
||||
_sigSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
let rssi = null;
|
||||
if (_source === 'wifi') rssi = d.signal_level ?? d.signal ?? null;
|
||||
else if (_source === 'bluetooth') rssi = d.rssi ?? null;
|
||||
else rssi = d.power_level ?? d.power ?? null;
|
||||
if (rssi !== null) {
|
||||
_lastSignal = parseFloat(rssi);
|
||||
_updateSignalDisplay(_lastSignal);
|
||||
}
|
||||
_maybeSample();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
function _maybeSample() {
|
||||
if (!_isRecording || _lastSignal === null) return;
|
||||
if (!_gpsPos) {
|
||||
const fb = _getFallbackPos();
|
||||
if (fb) _gpsPos = fb;
|
||||
else return;
|
||||
}
|
||||
|
||||
const { lat, lng } = _gpsPos;
|
||||
if (_lastLat !== null) {
|
||||
const dist = _haversineM(_lastLat, _lastLng, lat, lng);
|
||||
if (dist < _minDist) return;
|
||||
}
|
||||
|
||||
const intensity = _norm(_lastSignal, _source);
|
||||
_heatPoints.push([lat, lng, intensity]);
|
||||
_lastLat = lat;
|
||||
_lastLng = lng;
|
||||
|
||||
if (_heatLayer) {
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs(_heatPoints);
|
||||
}
|
||||
_updateCount();
|
||||
}
|
||||
|
||||
function _updateCount() {
|
||||
const el = document.getElementById('rfhmPointCount');
|
||||
if (el) el.textContent = _heatPoints.length;
|
||||
}
|
||||
|
||||
function _updateSignalDisplay(rssi) {
|
||||
const valEl = document.getElementById('rfhmLiveSignal');
|
||||
const barEl = document.getElementById('rfhmSignalBar');
|
||||
const statusEl = document.getElementById('rfhmSignalStatus');
|
||||
if (!valEl) return;
|
||||
|
||||
valEl.textContent = rssi !== null ? `${rssi.toFixed(1)} dBm` : '— dBm';
|
||||
|
||||
if (rssi !== null) {
|
||||
// Normalise to 0–100% for the bar
|
||||
const pct = Math.round(_norm(rssi, _source) * 100);
|
||||
if (barEl) barEl.style.width = pct + '%';
|
||||
|
||||
// Colour the value by strength
|
||||
let color, label;
|
||||
if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; }
|
||||
else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; }
|
||||
else { color = '#f59e0b'; label = 'Weak'; }
|
||||
valEl.style.color = color;
|
||||
if (barEl) barEl.style.background = color;
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = _isRecording
|
||||
? `${label} — recording point every ${_minDist}m`
|
||||
: `${label} — press Start Recording to begin`;
|
||||
}
|
||||
} else {
|
||||
if (barEl) barEl.style.width = '0%';
|
||||
valEl.style.color = 'var(--text-dim)';
|
||||
if (statusEl) statusEl.textContent = 'No signal data received yet';
|
||||
}
|
||||
}
|
||||
|
||||
function setSource(src) {
|
||||
_source = src;
|
||||
if (_active) _startSignalStream();
|
||||
}
|
||||
|
||||
function setMinDist(m) {
|
||||
_minDist = m;
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
_isRecording = true;
|
||||
_lastLat = null; _lastLng = null;
|
||||
const startBtn = document.getElementById('rfhmRecordBtn');
|
||||
const stopBtn = document.getElementById('rfhmStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); }
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
_isRecording = false;
|
||||
const startBtn = document.getElementById('rfhmRecordBtn');
|
||||
const stopBtn = document.getElementById('rfhmStopBtn');
|
||||
if (startBtn) startBtn.style.display = '';
|
||||
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); }
|
||||
}
|
||||
|
||||
function clearPoints() {
|
||||
_heatPoints = [];
|
||||
if (_heatLayer) {
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]);
|
||||
}
|
||||
_updateCount();
|
||||
}
|
||||
|
||||
function exportGeoJSON() {
|
||||
const features = _heatPoints.map(([lat, lng, intensity]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||
properties: { intensity, source: _source },
|
||||
}));
|
||||
const geojson = { type: 'FeatureCollection', features };
|
||||
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `rf_heatmap_${Date.now()}.geojson`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (!_map) return;
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
|
||||
_map.invalidateSize();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source lifecycle (start / stop / status) ──────────────────────
|
||||
|
||||
async function _checkSourceStatus() {
|
||||
const src = _source;
|
||||
let running = false;
|
||||
let detail = null;
|
||||
try {
|
||||
if (src === 'wifi') {
|
||||
const r = await fetch('/wifi/v2/scan/status');
|
||||
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; }
|
||||
} else if (src === 'bluetooth') {
|
||||
const r = await fetch('/api/bluetooth/scan/status');
|
||||
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; }
|
||||
} else if (src === 'scanner') {
|
||||
const r = await fetch('/listening/scanner/status');
|
||||
if (r.ok) { const d = await r.json(); running = !!d.running; }
|
||||
}
|
||||
} catch (_) {}
|
||||
return { running, detail };
|
||||
}
|
||||
|
||||
async function startSource() {
|
||||
const src = _source;
|
||||
const btn = document.getElementById('rfhmSourceStartBtn');
|
||||
const status = document.getElementById('rfhmSourceStatus');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (src === 'wifi') {
|
||||
// Try to find a monitor interface from the WiFi status first
|
||||
let iface = null;
|
||||
try {
|
||||
const st = await fetch('/wifi/v2/scan/status');
|
||||
if (st.ok) { const d = await st.json(); iface = d.interface || null; }
|
||||
} catch (_) {}
|
||||
if (!iface) {
|
||||
// Ask the user to enter an interface name
|
||||
const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):');
|
||||
if (!entered) { _updateSourceStatusUI(); return; }
|
||||
iface = entered.trim();
|
||||
}
|
||||
res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) });
|
||||
} else if (src === 'bluetooth') {
|
||||
res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) });
|
||||
} else if (src === 'scanner') {
|
||||
const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0';
|
||||
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
|
||||
res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) });
|
||||
}
|
||||
if (res && res.ok) {
|
||||
_ownedSource = true;
|
||||
_startSignalStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
await _updateSourceStatusUI();
|
||||
}
|
||||
|
||||
async function stopSource() {
|
||||
if (!_ownedSource) return;
|
||||
try {
|
||||
if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' });
|
||||
else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
||||
else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' });
|
||||
} catch (_) {}
|
||||
_ownedSource = false;
|
||||
await _updateSourceStatusUI();
|
||||
}
|
||||
|
||||
async function _updateSourceStatusUI() {
|
||||
const { running, detail } = await _checkSourceStatus();
|
||||
const row = document.getElementById('rfhmSourceStatusRow');
|
||||
const dotEl = document.getElementById('rfhmSourceDot');
|
||||
const textEl = document.getElementById('rfhmSourceStatusText');
|
||||
const startB = document.getElementById('rfhmSourceStartBtn');
|
||||
const stopB = document.getElementById('rfhmSourceStopBtn');
|
||||
if (!row) return;
|
||||
|
||||
const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' };
|
||||
const name = SOURCE_NAMES[_source] || _source;
|
||||
|
||||
if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)';
|
||||
if (textEl) textEl.textContent = running
|
||||
? `${name} running${detail ? ' · ' + detail : ''}`
|
||||
: `${name} not running`;
|
||||
if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; }
|
||||
if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none';
|
||||
|
||||
// Auto-subscribe to stream if source just became running
|
||||
if (running && !_sigSource) _startSignalStream();
|
||||
}
|
||||
|
||||
const SOURCE_HINTS = {
|
||||
wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.',
|
||||
bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.',
|
||||
scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.',
|
||||
};
|
||||
|
||||
function onSourceChange() {
|
||||
const src = document.getElementById('rfhmSource')?.value || 'wifi';
|
||||
const hint = document.getElementById('rfhmSourceHint');
|
||||
const dg = document.getElementById('rfhmDeviceGroup');
|
||||
if (hint) hint.textContent = SOURCE_HINTS[src] || '';
|
||||
if (dg) dg.style.display = src === 'scanner' ? '' : 'none';
|
||||
_lastSignal = null;
|
||||
_ownedSource = false;
|
||||
_updateSignalDisplay(null);
|
||||
_updateSourceStatusUI();
|
||||
// Re-subscribe to correct stream
|
||||
if (_sigSource) { _sigSource.close(); _sigSource = null; }
|
||||
_startSignalStream();
|
||||
}
|
||||
|
||||
function _loadDevices() {
|
||||
const sel = document.getElementById('rfhmDevice');
|
||||
if (!sel) return;
|
||||
fetch('/devices').then(r => r.json()).then(devices => {
|
||||
if (!devices || devices.length === 0) {
|
||||
sel.innerHTML = '<option value="">No SDR devices detected</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = devices.map(d => {
|
||||
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
|
||||
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
|
||||
}).join('');
|
||||
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
|
||||
}
|
||||
|
||||
function _getFallbackPos() {
|
||||
// Try observer location from localStorage (shared across all map modes)
|
||||
try {
|
||||
const stored = localStorage.getItem('observerLocation');
|
||||
if (stored) {
|
||||
const p = JSON.parse(stored);
|
||||
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
|
||||
return { lat: p.lat, lng: p.lon };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
// Try manual coord inputs
|
||||
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
|
||||
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
|
||||
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
|
||||
return null;
|
||||
}
|
||||
|
||||
function setManualCoords() {
|
||||
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
|
||||
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
|
||||
if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) {
|
||||
_map.setView([lat, lng], _map.getZoom(), { animate: false });
|
||||
}
|
||||
}
|
||||
|
||||
function useObserverLocation() {
|
||||
try {
|
||||
const stored = localStorage.getItem('observerLocation');
|
||||
if (stored) {
|
||||
const p = JSON.parse(stored);
|
||||
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
|
||||
const latEl = document.getElementById('rfhmManualLat');
|
||||
const lonEl = document.getElementById('rfhmManualLon');
|
||||
if (latEl) latEl.value = p.lat.toFixed(5);
|
||||
if (lonEl) lonEl.value = p.lon.toFixed(5);
|
||||
if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function init() {
|
||||
_active = true;
|
||||
_loadDevices();
|
||||
onSourceChange();
|
||||
|
||||
// Pre-fill manual coords from observer location if available
|
||||
const fallback = _getFallbackPos();
|
||||
if (fallback) {
|
||||
const latEl = document.getElementById('rfhmManualLat');
|
||||
const lonEl = document.getElementById('rfhmManualLon');
|
||||
if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5);
|
||||
if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5);
|
||||
}
|
||||
|
||||
_updateSignalDisplay(null);
|
||||
_updateSourceStatusUI();
|
||||
_ensureLeafletHeat(() => {
|
||||
setTimeout(() => {
|
||||
_initMap();
|
||||
_startGPS();
|
||||
_startSignalStream();
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
_active = false;
|
||||
if (_isRecording) stopRecording();
|
||||
if (_ownedSource) stopSource();
|
||||
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
|
||||
if (_sigSource) { _sigSource.close(); _sigSource = null; }
|
||||
}
|
||||
|
||||
return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource };
|
||||
})();
|
||||
|
||||
window.RFHeatmap = RFHeatmap;
|
||||
@@ -269,12 +269,10 @@ const SpyStations = (function() {
|
||||
*/
|
||||
function tuneToStation(stationId, freqKhz) {
|
||||
const freqMhz = freqKhz / 1000;
|
||||
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
|
||||
|
||||
// Find the station and determine mode
|
||||
const station = stations.find(s => s.id === stationId);
|
||||
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
||||
sessionStorage.setItem('tuneMode', tuneMode);
|
||||
|
||||
const stationName = station ? station.name : 'Station';
|
||||
|
||||
@@ -282,12 +280,18 @@ const SpyStations = (function() {
|
||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||
}
|
||||
|
||||
// Switch to listening post mode
|
||||
if (typeof selectMode === 'function') {
|
||||
selectMode('listening');
|
||||
} else if (typeof switchMode === 'function') {
|
||||
switchMode('listening');
|
||||
// Switch to spectrum waterfall mode and tune after mode init.
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('waterfall');
|
||||
} else if (typeof selectMode === 'function') {
|
||||
selectMode('waterfall');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||
Waterfall.quickTune(freqMhz, tuneMode);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,7 +309,7 @@ const SpyStations = (function() {
|
||||
* Check if we arrived from another page with a tune request
|
||||
*/
|
||||
function checkTuneFrequency() {
|
||||
// This is for the listening post to check - spy stations sets, listening post reads
|
||||
// Reserved for cross-mode tune handoff behavior.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,7 +449,7 @@ const SpyStations = (function() {
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">How to Listen</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
|
||||
Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured.
|
||||
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
||||
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
||||
</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user