Merge upstream/main and resolve adsb_dashboard.html conflict

Take upstream's crosshair animation system and updated selectAircraft(icao, source) signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-02-23 23:16:39 -05:00
77 changed files with 12673 additions and 9572 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,
};
})();

View File

@@ -944,21 +944,36 @@ const BluetoothMode = (function() {
}
}
async function stopScan() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' });
} else {
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
}
setScanning(false);
stopEventStream();
} catch (err) {
console.error('Failed to stop scan:', err);
}
}
async function stopScan() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const timeoutMs = isAgentMode ? 8000 : 2200;
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
// Optimistic UI teardown keeps mode changes responsive.
setScanning(false);
stopEventStream();
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
} else {
await fetch('/api/bluetooth/scan/stop', {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
}
} catch (err) {
console.error('Failed to stop scan:', err);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function setScanning(scanning) {
isScanning = scanning;

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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>

View File

@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
let sstvGeneralScopeWaveBuffer = [];
let sstvGeneralScopeDisplayWave = [];
const SSTV_GENERAL_SCOPE_LEN = 200;
const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048;
const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96;
let sstvGeneralScopeRms = 0;
let sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
let sstvGeneralScopeLastWaveAt = 0;
let sstvGeneralScopeLastInputSample = 0;
/**
* Initialize the SSTV General mode
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
/**
* Initialize signal scope canvas
*/
function resizeSstvGeneralScopeCanvas(canvas) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * dpr));
const height = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function applySstvGeneralScopeData(scopeData) {
if (!scopeData || typeof scopeData !== 'object') return;
sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0;
sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0;
if (scopeData.tone !== undefined) {
sstvGeneralScopeTone = scopeData.tone;
}
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
for (const packedSample of scopeData.waveform) {
const sample = Number(packedSample);
if (!Number.isFinite(sample)) continue;
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample);
}
if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) {
sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN);
}
sstvGeneralScopeLastWaveAt = performance.now();
}
}
function initSstvGeneralScope() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
if (sstvGeneralScopeAnim) {
cancelAnimationFrame(sstvGeneralScopeAnim);
sstvGeneralScopeAnim = null;
}
resizeSstvGeneralScopeCanvas(canvas);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
drawSstvGeneralScope();
}
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
resizeSstvGeneralScopeCanvas(ctx.canvas);
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
// Envelope
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Actual waveform trace
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
if (sstvGeneralScopeWaveBuffer.length > 1) {
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
const sourceLen = sstvGeneralScopeWaveBuffer.length;
const sourceWindow = Math.min(sourceLen, 1536);
const sourceStart = sourceLen - sourceWindow;
if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) {
sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0);
}
for (let i = 0; i < waveformPointCount; i++) {
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
const end = Math.max(start + 1, Math.min(sourceLen, b));
let sum = 0;
let count = 0;
for (let j = start; j < end; j++) {
sum += sstvGeneralScopeWaveBuffer[j];
count++;
}
const targetSample = count > 0 ? (sum / count) : 0;
sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
}
ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)';
ctx.lineWidth = 1.7;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = waveIsFresh ? 6 : 2;
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
ctx.beginPath();
const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9);
ctx.moveTo(0, firstY);
for (let i = 1; i < waveformPointCount - 1; i++) {
const x = i * stepX;
const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9);
const nx = (i + 1) * stepX;
const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9);
const cx = (x + nx) / 2;
const cy = (y + ny) / 2;
ctx.quadraticCurveTo(x, y, cx, cy);
}
const lastX = (waveformPointCount - 1) * stepX;
const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
ctx.lineTo(lastX, lastY);
ctx.stroke();
if (!waveIsFresh) {
for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) {
sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY;
}
}
}
ctx.shadowBlur = 0;
// Peak indicator
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
if (sstvGeneralScopeRms > 900 && waveIsFresh) {
statusLabel.textContent = 'DEMODULATING';
statusLabel.style.color = '#c080ff';
} else if (sstvGeneralScopeRms > 500) {
statusLabel.textContent = 'CARRIER';
statusLabel.style.color = '#e0b8ff';
} else {
statusLabel.textContent = 'QUIET';
statusLabel.style.color = '#555';
}
}
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeHistory = [];
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
}
/**
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
applySstvGeneralScopeData(data);
}
} catch (err) {
console.error('Failed to parse SSE message:', err);

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,20 @@ let websdrMarkers = [];
let websdrReceivers = [];
let websdrInitialized = false;
let websdrSpyStationsLoaded = false;
let websdrMapType = null;
let websdrGlobe = null;
let websdrGlobePopup = null;
let websdrSelectedReceiverIndex = null;
let websdrGlobeScriptPromise = null;
let websdrResizeObserver = null;
let websdrResizeHooked = false;
let websdrGlobeFallbackNotified = false;
const WEBSDR_GLOBE_SCRIPT_URLS = [
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
];
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
// KiwiSDR audio state
let kiwiWebSocket = null;
@@ -29,54 +43,50 @@ const KIWI_SAMPLE_RATE = 12000;
async function initWebSDR() {
if (websdrInitialized) {
if (websdrMap) {
setTimeout(() => websdrMap.invalidateSize(), 100);
}
setTimeout(invalidateWebSDRViewport, 100);
return;
}
const mapEl = document.getElementById('websdrMap');
if (!mapEl || typeof L === 'undefined') return;
if (!mapEl) return;
// Calculate minimum zoom so tiles fill the container vertically
const mapHeight = mapEl.clientHeight || 500;
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
const globeReady = await ensureWebsdrGlobeLibrary();
websdrMap = L.map('websdrMap', {
center: [20, 0],
zoom: Math.max(minZoom, 2),
minZoom: Math.max(minZoom, 2),
zoomControl: true,
maxBounds: [[-85, -360], [85, 360]],
maxBoundsViscosity: 1.0,
});
// Wait for a paint frame so the browser computes layout after the
// display:flex change in switchMode. Without this, Globe()(mapEl) can
// run before clientWidth/clientHeight are non-zero (especially when
// scripts are served from cache and resolve before the first layout pass).
await new Promise(resolve => requestAnimationFrame(resolve));
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
await Settings.init();
Settings.createTileLayer().addTo(websdrMap);
Settings.registerMap(websdrMap);
// If the mode was switched away while scripts were loading, abort so
// websdrInitialized stays false and we retry cleanly next time.
if (!mapEl.clientWidth || !mapEl.clientHeight) return;
if (globeReady && initWebsdrGlobe(mapEl)) {
websdrMapType = 'globe';
} else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) {
websdrMapType = 'leaflet';
if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') {
showNotification('WebSDR', '3D globe unavailable, using fallback map');
websdrGlobeFallbackNotified = true;
}
} else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
className: 'tile-layer-cyan',
}).addTo(websdrMap);
console.error('[WEBSDR] Unable to initialize globe or map renderer');
return;
}
// Match background to tile ocean color so any remaining edge is seamless
mapEl.style.background = '#1a1d29';
websdrInitialized = true;
if (!websdrSpyStationsLoaded) {
loadSpyStationPresets();
}
setupWebsdrResizeHandling(mapEl);
if (websdrReceivers.length > 0) {
plotReceiversOnMap(websdrReceivers);
}
[100, 300, 600, 1000].forEach(delay => {
setTimeout(() => {
if (websdrMap) websdrMap.invalidateSize();
}, delay);
setTimeout(invalidateWebSDRViewport, delay);
});
}
@@ -94,6 +104,8 @@ function searchReceivers(refresh) {
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
websdrSelectedReceiverIndex = null;
hideWebsdrGlobePopup();
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);
@@ -107,6 +119,11 @@ function searchReceivers(refresh) {
// ============== MAP ==============
function plotReceiversOnMap(receivers) {
if (websdrMapType === 'globe' && websdrGlobe) {
plotReceiversOnGlobe(receivers);
return;
}
if (!websdrMap) return;
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
@@ -144,6 +161,369 @@ function plotReceiversOnMap(receivers) {
}
}
async function ensureWebsdrGlobeLibrary() {
if (typeof window.Globe === 'function') return true;
if (!isWebglSupported()) return false;
if (!websdrGlobeScriptPromise) {
websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS
.reduce(
(promise, src) => promise.then(() => loadWebsdrScript(src)),
Promise.resolve()
)
.then(() => typeof window.Globe === 'function')
.catch((error) => {
console.warn('[WEBSDR] Failed to load globe scripts:', error);
return false;
});
}
const loaded = await websdrGlobeScriptPromise;
if (!loaded) {
websdrGlobeScriptPromise = null;
}
return loaded;
}
function loadWebsdrScript(src) {
return new Promise((resolve, reject) => {
const selector = `script[data-websdr-src="${src}"]`;
const existing = document.querySelector(selector);
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve();
return;
}
if (existing.dataset.failed === 'true') {
existing.remove();
} else {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
return;
}
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.websdrSrc = src;
script.onload = () => {
script.dataset.loaded = 'true';
resolve();
};
script.onerror = () => {
script.dataset.failed = 'true';
reject(new Error(`Failed to load ${src}`));
};
document.head.appendChild(script);
});
}
function isWebglSupported() {
try {
const canvas = document.createElement('canvas');
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
} catch (_) {
return false;
}
}
function initWebsdrGlobe(mapEl) {
if (typeof window.Globe !== 'function' || !isWebglSupported()) return false;
mapEl.innerHTML = '';
mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)';
mapEl.style.cursor = 'grab';
websdrGlobe = window.Globe()(mapEl)
.backgroundColor('rgba(0,0,0,0)')
.globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL)
.showAtmosphere(true)
.atmosphereColor('#3bb9ff')
.atmosphereAltitude(0.17)
.pointRadius('radius')
.pointAltitude('altitude')
.pointColor('color')
.pointsTransitionDuration(250)
.pointLabel(point => point.label || '')
.onPointHover(point => {
mapEl.style.cursor = point ? 'pointer' : 'grab';
})
.onPointClick((point, event) => {
if (!point) return;
showWebsdrGlobePopup(point, event);
});
const controls = websdrGlobe.controls();
if (controls) {
controls.autoRotate = true;
controls.autoRotateSpeed = 0.25;
controls.enablePan = false;
controls.minDistance = 140;
controls.maxDistance = 380;
controls.rotateSpeed = 0.7;
controls.zoomSpeed = 0.8;
}
ensureWebsdrGlobePopup(mapEl);
resizeWebsdrGlobe();
return true;
}
async function initWebsdrLeaflet(mapEl) {
if (typeof L === 'undefined') return false;
mapEl.innerHTML = '';
const mapHeight = mapEl.clientHeight || 500;
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
websdrMap = L.map('websdrMap', {
center: [20, 0],
zoom: Math.max(minZoom, 2),
minZoom: Math.max(minZoom, 2),
zoomControl: true,
maxBounds: [[-85, -360], [85, 360]],
maxBoundsViscosity: 1.0,
});
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
await Settings.init();
Settings.createTileLayer().addTo(websdrMap);
Settings.registerMap(websdrMap);
} else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
className: 'tile-layer-cyan',
}).addTo(websdrMap);
}
mapEl.style.background = '#1a1d29';
return true;
}
function setupWebsdrResizeHandling(mapEl) {
if (typeof ResizeObserver !== 'undefined') {
if (websdrResizeObserver) {
websdrResizeObserver.disconnect();
}
websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport());
websdrResizeObserver.observe(mapEl);
}
if (!websdrResizeHooked) {
window.addEventListener('resize', invalidateWebSDRViewport);
window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120));
websdrResizeHooked = true;
}
}
function invalidateWebSDRViewport() {
if (websdrMapType === 'globe') {
resizeWebsdrGlobe();
return;
}
if (websdrMap && typeof websdrMap.invalidateSize === 'function') {
websdrMap.invalidateSize({ pan: false, animate: false });
}
}
function resizeWebsdrGlobe() {
if (!websdrGlobe) return;
const mapEl = document.getElementById('websdrMap');
if (!mapEl) return;
const width = mapEl.clientWidth;
const height = mapEl.clientHeight;
if (!width || !height) return;
websdrGlobe.width(width);
websdrGlobe.height(height);
}
function plotReceiversOnGlobe(receivers) {
if (!websdrGlobe) return;
const points = [];
receivers.forEach((rx, idx) => {
const lat = Number(rx.lat);
const lon = Number(rx.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const selected = idx === websdrSelectedReceiverIndex;
points.push({
lat: lat,
lng: lon,
receiverIndex: idx,
radius: selected ? 0.52 : 0.38,
altitude: selected ? 0.1 : 0.04,
color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'),
label: buildWebsdrPointLabel(rx, idx),
});
});
websdrGlobe.pointsData(points);
if (points.length > 0) {
if (websdrSelectedReceiverIndex != null) {
const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex);
if (selectedPoint) {
websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900);
return;
}
}
const center = computeWebsdrGlobeCenter(points);
websdrGlobe.pointOfView(center, 900);
}
}
function computeWebsdrGlobeCenter(points) {
if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 };
let x = 0;
let y = 0;
let z = 0;
points.forEach(point => {
const latRad = point.lat * Math.PI / 180;
const lonRad = point.lng * Math.PI / 180;
x += Math.cos(latRad) * Math.cos(lonRad);
y += Math.cos(latRad) * Math.sin(lonRad);
z += Math.sin(latRad);
});
const count = points.length;
x /= count;
y /= count;
z /= count;
const hyp = Math.sqrt((x * x) + (y * y));
const centerLat = Math.atan2(z, hyp) * 180 / Math.PI;
const centerLng = Math.atan2(y, x) * 180 / Math.PI;
let meanAngularDistance = 0;
const centerLatRad = centerLat * Math.PI / 180;
const centerLngRad = centerLng * Math.PI / 180;
points.forEach(point => {
const latRad = point.lat * Math.PI / 180;
const lonRad = point.lng * Math.PI / 180;
const cosAngle = (
(Math.sin(centerLatRad) * Math.sin(latRad)) +
(Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad))
);
const safeCos = Math.max(-1, Math.min(1, cosAngle));
meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI;
});
meanAngularDistance /= count;
const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45)));
return { lat: centerLat, lng: centerLng, altitude: altitude };
}
function ensureWebsdrGlobePopup(mapEl) {
if (websdrGlobePopup) {
if (websdrGlobePopup.parentElement !== mapEl) {
mapEl.appendChild(websdrGlobePopup);
}
return;
}
websdrGlobePopup = document.createElement('div');
websdrGlobePopup.id = 'websdrGlobePopup';
websdrGlobePopup.style.position = 'absolute';
websdrGlobePopup.style.minWidth = '220px';
websdrGlobePopup.style.maxWidth = '260px';
websdrGlobePopup.style.padding = '10px';
websdrGlobePopup.style.borderRadius = '8px';
websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)';
websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)';
websdrGlobePopup.style.backdropFilter = 'blur(4px)';
websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)';
websdrGlobePopup.style.color = 'var(--text-primary)';
websdrGlobePopup.style.display = 'none';
websdrGlobePopup.style.zIndex = '20';
mapEl.appendChild(websdrGlobePopup);
if (!mapEl.dataset.websdrPopupHooked) {
mapEl.addEventListener('click', (event) => {
if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return;
if (event.target.closest('#websdrGlobePopup')) return;
hideWebsdrGlobePopup();
});
mapEl.dataset.websdrPopupHooked = 'true';
}
}
function showWebsdrGlobePopup(point, event) {
if (!websdrGlobePopup || !point || point.receiverIndex == null) return;
const rx = websdrReceivers[point.receiverIndex];
if (!rx) return;
const mapEl = document.getElementById('websdrMap');
if (!mapEl) return;
websdrSelectedReceiverIndex = point.receiverIndex;
renderReceiverList(websdrReceivers);
plotReceiversOnGlobe(websdrReceivers);
websdrGlobePopup.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: start; gap: 10px; margin-bottom: 6px;">
<strong style="font-size: 12px; color: var(--accent-cyan);">${escapeHtmlWebsdr(rx.name)}</strong>
<button type="button" data-websdr-popup-close style="border: none; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 14px; line-height: 1;">&times;</button>
</div>
${rx.location ? `<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 3px;">${escapeHtmlWebsdr(rx.location)}</div>` : ''}
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</div>
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 10px;">Users: ${rx.users}/${rx.users_max}</div>
<button type="button" data-websdr-listen style="width: 100%; padding: 5px 10px; background: #00d4ff; color: #041018; border: none; border-radius: 4px; cursor: pointer; font-weight: 700;">Listen</button>
`;
websdrGlobePopup.style.display = 'block';
const rect = mapEl.getBoundingClientRect();
const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2);
const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2);
const popupWidth = 260;
const popupHeight = 155;
const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12));
const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12));
websdrGlobePopup.style.left = `${left}px`;
websdrGlobePopup.style.top = `${top}px`;
const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]');
if (closeBtn) {
closeBtn.onclick = () => hideWebsdrGlobePopup();
}
const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]');
if (listenBtn) {
listenBtn.onclick = () => selectReceiver(point.receiverIndex);
}
if (event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
}
function hideWebsdrGlobePopup() {
if (websdrGlobePopup) {
websdrGlobePopup.style.display = 'none';
}
}
function buildWebsdrPointLabel(rx, idx) {
const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location';
const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna');
return `
<div style="padding: 4px 6px; font-size: 11px; background: rgba(4, 12, 19, 0.9); border: 1px solid rgba(0,212,255,0.28); border-radius: 4px;">
<div style="color: #00d4ff; font-weight: 600;">${escapeHtmlWebsdr(rx.name)}</div>
<div style="color: #a5b1c3;">${location}</div>
<div style="color: #8f9fb3;">${antenna} · ${rx.users}/${rx.users_max}</div>
<div style="color: #7a899b; margin-top: 2px;">Receiver #${idx + 1}</div>
</div>
`;
}
// ============== RECEIVER LIST ==============
function renderReceiverList(receivers) {
@@ -155,12 +535,16 @@ function renderReceiverList(receivers) {
return;
}
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => {
const selected = idx === websdrSelectedReceiverIndex;
const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent';
const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)';
return `
<div style="padding: 8px 8px 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s; border-left: 2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}; background: ${baseBg};"
onmouseover="this.style.background='${hoverBg}'" onmouseout="this.style.background='${baseBg}'"
onclick="selectReceiver(${idx})">
<div style="display: flex; justify-content: space-between; align-items: center;">
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
<strong style="font-size: 11px; color: ${selected ? 'var(--accent-cyan)' : 'var(--text-primary)'};">${escapeHtmlWebsdr(rx.name)}</strong>
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
</div>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
@@ -168,7 +552,8 @@ function renderReceiverList(receivers) {
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
</div>
</div>
`).join('');
`;
}).join('');
}
// ============== SELECT RECEIVER ==============
@@ -180,14 +565,30 @@ function selectReceiver(index) {
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
const mode = document.getElementById('websdrMode_select')?.value || 'am';
websdrSelectedReceiverIndex = index;
renderReceiverList(websdrReceivers);
focusReceiverOnMap(rx);
hideWebsdrGlobePopup();
kiwiReceiverName = rx.name;
// Connect via backend proxy
connectToReceiver(rx.url, freqKhz, mode);
}
// Highlight on map
if (websdrMap && rx.lat != null && rx.lon != null) {
websdrMap.setView([rx.lat, rx.lon], 6);
function focusReceiverOnMap(rx) {
const lat = Number(rx.lat);
const lon = Number(rx.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
if (websdrMapType === 'globe' && websdrGlobe) {
plotReceiversOnGlobe(websdrReceivers);
websdrGlobe.pointOfView({ lat: lat, lng: lon, altitude: 1.4 }, 900);
return;
}
if (websdrMap) {
websdrMap.setView([lat, lon], 6);
}
}
@@ -551,6 +952,8 @@ function tuneToSpyStation(stationId, freqKhz) {
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
websdrSelectedReceiverIndex = null;
hideWebsdrGlobePopup();
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);

View File

@@ -572,8 +572,8 @@ const WiFiMode = (function() {
}
}
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
// Stop polling
if (pollTimer) {
@@ -585,26 +585,41 @@ const WiFiMode = (function() {
stopAgentDeepScanPolling();
// Close event stream
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Stop scan on server (local or agent)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' });
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
}
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
}
setScanning(false);
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Update UI immediately so mode transitions are responsive even if the
// backend needs extra time to terminate subprocesses.
setScanning(false);
// Stop scan on server (local or agent)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const timeoutMs = isAgentMode ? 8000 : 2200;
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
}
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function setScanning(scanning, mode = null) {
isScanning = scanning;