feat: ship waterfall receiver overhaul and platform mode updates

This commit is contained in:
Smittix
2026-02-22 23:22:37 +00:00
parent 5d4b61b4c3
commit 5f480caa3f
41 changed files with 7635 additions and 3516 deletions

View File

@@ -1,549 +0,0 @@
/**
* Analytics Dashboard Module
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
*/
const Analytics = (function () {
'use strict';
let refreshTimer = null;
let replayTimer = null;
let replaySessions = [];
let replayEvents = [];
let replayIndex = 0;
function init() {
refresh();
loadReplaySessions();
if (!refreshTimer) {
refreshTimer = setInterval(refresh, 5000);
}
}
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
pauseReplay();
}
function refresh() {
Promise.all([
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
fetch('/correlation').then(r => r.json()).catch(() => null),
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
if (summary) renderSummary(summary);
if (activity) renderSparklines(activity.sparklines || {});
if (insights) renderInsights(insights);
if (patterns) renderPatterns(patterns.patterns || []);
if (alerts) renderAlerts(alerts.events || []);
if (correlations) renderCorrelations(correlations);
if (geofences) renderGeofences(geofences.zones || []);
});
}
function renderSummary(data) {
const counts = data.counts || {};
_setText('analyticsCountAdsb', counts.adsb || 0);
_setText('analyticsCountAis', counts.ais || 0);
_setText('analyticsCountWifi', counts.wifi || 0);
_setText('analyticsCountBt', counts.bluetooth || 0);
_setText('analyticsCountDsc', counts.dsc || 0);
_setText('analyticsCountAcars', counts.acars || 0);
_setText('analyticsCountVdl2', counts.vdl2 || 0);
_setText('analyticsCountAprs', counts.aprs || 0);
_setText('analyticsCountMesh', counts.meshtastic || 0);
const health = data.health || {};
const container = document.getElementById('analyticsHealth');
if (container) {
let html = '';
const modeLabels = {
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
};
for (const [mode, info] of Object.entries(health)) {
if (mode === 'sdr_devices') continue;
const running = info && info.running;
const label = modeLabels[mode] || mode;
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
}
container.innerHTML = html;
}
const squawks = data.squawks || [];
const sqSection = document.getElementById('analyticsSquawkSection');
const sqList = document.getElementById('analyticsSquawkList');
if (sqSection && sqList) {
if (squawks.length > 0) {
sqSection.style.display = '';
sqList.innerHTML = squawks.map(s =>
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
).join('');
} else {
sqSection.style.display = 'none';
}
}
}
function renderSparklines(sparklines) {
const map = {
adsb: 'analyticsSparkAdsb',
ais: 'analyticsSparkAis',
wifi: 'analyticsSparkWifi',
bluetooth: 'analyticsSparkBt',
dsc: 'analyticsSparkDsc',
acars: 'analyticsSparkAcars',
vdl2: 'analyticsSparkVdl2',
aprs: 'analyticsSparkAprs',
meshtastic: 'analyticsSparkMesh',
};
for (const [mode, elId] of Object.entries(map)) {
const el = document.getElementById(elId);
if (!el) continue;
const data = sparklines[mode] || [];
if (data.length < 2) {
el.innerHTML = '';
continue;
}
const max = Math.max(...data, 1);
const w = 100;
const h = 24;
const step = w / (data.length - 1);
const points = data.map((v, i) =>
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
).join(' ');
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
}
}
function renderInsights(data) {
const cards = data.cards || [];
const topChanges = data.top_changes || [];
const cardsEl = document.getElementById('analyticsInsights');
const changesEl = document.getElementById('analyticsTopChanges');
if (cardsEl) {
if (!cards.length) {
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
} else {
cardsEl.innerHTML = cards.map(c => {
const sev = _esc(c.severity || 'low');
const title = _esc(c.title || 'Insight');
const value = _esc(c.value || '--');
const label = _esc(c.label || '');
const detail = _esc(c.detail || '');
return '<div class="analytics-insight-card ' + sev + '">' +
'<div class="insight-title">' + title + '</div>' +
'<div class="insight-value">' + value + '</div>' +
'<div class="insight-label">' + label + '</div>' +
'<div class="insight-detail">' + detail + '</div>' +
'</div>';
}).join('');
}
}
if (changesEl) {
if (!topChanges.length) {
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
} else {
changesEl.innerHTML = topChanges.map(item => {
const mode = _esc(item.mode_label || item.mode || '');
const deltaRaw = Number(item.delta || 0);
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
const delta = _esc(item.signed_delta || String(deltaRaw));
const recentAvg = _esc(item.recent_avg);
const prevAvg = _esc(item.previous_avg);
return '<div class="analytics-change-row">' +
'<span class="mode">' + mode + '</span>' +
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
'</div>';
}).join('');
}
}
}
function renderPatterns(patterns) {
const container = document.getElementById('analyticsPatternList');
if (!container) return;
if (!patterns || patterns.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
return;
}
const modeLabels = {
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
};
const sorted = patterns
.slice()
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
.slice(0, 20);
container.innerHTML = sorted.map(p => {
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
const period = _humanPeriod(Number(p.period_seconds || 0));
const occurrences = Number(p.occurrences || 0);
const deviceId = _shortId(p.device_id || '--');
return '<div class="analytics-pattern-item">' +
'<div class="pattern-main">' +
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
'</div>' +
'<div class="pattern-meta">' +
'<span>Period: ' + _esc(period) + '</span>' +
'<span>Hits: ' + _esc(occurrences) + '</span>' +
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderAlerts(events) {
const container = document.getElementById('analyticsAlertFeed');
if (!container) return;
if (!events || events.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
return;
}
container.innerHTML = events.slice(0, 20).map(e => {
const sev = e.severity || 'medium';
const title = e.title || e.event_type || 'Alert';
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
return '<div class="analytics-alert-item">' +
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
'<span>' + _esc(title) + '</span>' +
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
'</div>';
}).join('');
}
function renderCorrelations(data) {
const container = document.getElementById('analyticsCorrelations');
if (!container) return;
const pairs = (data && data.correlations) || [];
if (pairs.length === 0) {
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
return;
}
container.innerHTML = pairs.slice(0, 20).map(p => {
const conf = Math.round((p.confidence || 0) * 100);
return '<div class="analytics-correlation-pair">' +
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
'<span style="color:var(--text-dim)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
function searchTarget() {
const input = document.getElementById('analyticsTargetQuery');
const summaryEl = document.getElementById('analyticsTargetSummary');
const q = (input && input.value || '').trim();
if (!q) {
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
renderTargetResults([]);
return;
}
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
.then((r) => r.json())
.then((data) => {
const results = data.results || [];
if (summaryEl) {
const modeCounts = data.mode_counts || {};
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
}
renderTargetResults(results);
})
.catch((err) => {
if (summaryEl) summaryEl.textContent = 'Search failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Target View Search', err, { onRetry: searchTarget });
}
});
}
function renderTargetResults(results) {
const container = document.getElementById('analyticsTargetResults');
if (!container) return;
if (!results || !results.length) {
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
return;
}
container.innerHTML = results.map((item) => {
const title = _esc(item.title || item.id || 'Entity');
const subtitle = _esc(item.subtitle || '');
const mode = _esc(item.mode || 'unknown');
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
const lastSeen = _esc(item.last_seen || '');
return '<div class="analytics-target-item">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
'<div class="meta"><span>' + subtitle + '</span>' +
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
(confidence ? '<span>' + confidence + '</span>' : '') +
'</div>' +
'</div>';
}).join('');
}
function loadReplaySessions() {
const select = document.getElementById('analyticsReplaySelect');
if (!select) return;
fetch('/recordings?limit=60')
.then((r) => r.json())
.then((data) => {
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
if (!replaySessions.length) {
select.innerHTML = '<option value="">No recordings</option>';
return;
}
select.innerHTML = replaySessions.map((rec) => {
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
}).join('');
const pendingReplay = localStorage.getItem('analyticsReplaySession');
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
select.value = pendingReplay;
localStorage.removeItem('analyticsReplaySession');
loadReplay();
}
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
}
});
}
function loadReplay() {
pauseReplay();
replayEvents = [];
replayIndex = 0;
const select = document.getElementById('analyticsReplaySelect');
const meta = document.getElementById('analyticsReplayMeta');
const timeline = document.getElementById('analyticsReplayTimeline');
if (!select || !meta || !timeline) return;
const id = select.value;
if (!id) {
meta.textContent = 'Select a recording';
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
return;
}
meta.textContent = 'Loading replay events...';
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
.then((r) => r.json())
.then((data) => {
replayEvents = data.events || [];
replayIndex = 0;
if (!replayEvents.length) {
meta.textContent = 'No events found in selected recording';
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const rec = replaySessions.find((s) => s.id === id);
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
renderReplayWindow();
})
.catch((err) => {
meta.textContent = 'Replay load failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay', err, { onRetry: loadReplay });
}
});
}
function playReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
if (replayTimer) return;
replayTimer = setInterval(() => {
if (replayIndex >= replayEvents.length - 1) {
pauseReplay();
return;
}
replayIndex += 1;
renderReplayWindow();
}, 260);
}
function pauseReplay() {
if (replayTimer) {
clearInterval(replayTimer);
replayTimer = null;
}
}
function stepReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
pauseReplay();
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
renderReplayWindow();
}
function renderReplayWindow() {
const timeline = document.getElementById('analyticsReplayTimeline');
const meta = document.getElementById('analyticsReplayMeta');
if (!timeline || !meta) return;
const total = replayEvents.length;
if (!total) {
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const start = Math.max(0, replayIndex - 15);
const end = Math.min(total, replayIndex + 20);
const windowed = replayEvents.slice(start, end);
timeline.innerHTML = windowed.map((row, i) => {
const absolute = start + i;
const active = absolute === replayIndex;
const eventType = _esc(row.event_type || 'event');
const mode = _esc(row.mode || '--');
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
const detail = summarizeReplayEvent(row.event || {});
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
'</div>';
}).join('');
meta.textContent = `Event ${replayIndex + 1}/${total}`;
}
function summarizeReplayEvent(event) {
if (!event || typeof event !== 'object') return 'No details';
if (event.callsign) return `Callsign ${event.callsign}`;
if (event.icao) return `ICAO ${event.icao}`;
if (event.ssid) return `SSID ${event.ssid}`;
if (event.bssid) return `BSSID ${event.bssid}`;
if (event.address) return `Address ${event.address}`;
if (event.name) return `Name ${event.name}`;
const keys = Object.keys(event);
if (!keys.length) return 'No fields';
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
}
function _setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function _esc(s) {
if (typeof s !== 'string') s = String(s == null ? '' : s);
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _shortId(value) {
const text = String(value || '');
if (text.length <= 18) return text;
return text.slice(0, 8) + '...' + text.slice(-6);
}
function _humanPeriod(seconds) {
if (!isFinite(seconds) || seconds <= 0) return '--';
if (seconds < 60) return Math.round(seconds) + 's';
const mins = seconds / 60;
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
const hours = mins / 60;
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
}
return {
init,
destroy,
refresh,
addGeofence,
deleteGeofence,
exportData,
searchTarget,
loadReplay,
playReplay,
pauseReplay,
stepReplay,
loadReplaySessions,
};
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
/* 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;

View File

@@ -0,0 +1,456 @@
/* 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 0100% 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;

2134
static/js/modes/waterfall.js Normal file

File diff suppressed because it is too large Load Diff