mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 15:20:00 -07:00
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:
@@ -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)">↔</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
3481
static/js/modes/waterfall.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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: '© OpenStreetMap contributors © 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: '© OpenStreetMap contributors © 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;">×</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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user