Merge upstream/main and resolve weather-satellite.js conflict

Resolved conflict in static/js/modes/weather-satellite.js:
- Kept allPasses state variable and applyPassFilter() for satellite pass filtering
- Kept satellite select dropdown listener for filter feature
- Adopted upstream's optimistic stop() UI pattern for better responsiveness
- Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-02-26 00:37:02 -05:00
71 changed files with 13181 additions and 3658 deletions

View File

@@ -9,22 +9,45 @@ const GPS = (function() {
let lastPosition = null;
let lastSky = null;
let skyPollTimer = null;
let statusPollTimer = null;
let themeObserver = null;
let skyRenderer = null;
let skyRendererInitAttempted = false;
// Constellation color map
const CONST_COLORS = {
'GPS': '#00d4ff',
'GLONASS': '#00ff88',
let skyRendererInitPromise = null;
// Constellation color map
const CONST_COLORS = {
'GPS': '#00d4ff',
'GLONASS': '#00ff88',
'Galileo': '#ff8800',
'BeiDou': '#ff4466',
'SBAS': '#ffdd00',
'QZSS': '#cc66ff',
};
'SBAS': '#ffdd00',
'QZSS': '#cc66ff',
};
const CONST_ALTITUDES = {
'GPS': 0.28,
'GLONASS': 0.27,
'Galileo': 0.29,
'BeiDou': 0.30,
'SBAS': 0.34,
'QZSS': 0.31,
};
const GPS_GLOBE_SCRIPT_URLS = [
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
];
const GPS_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
const GPS_SATELLITE_ICON_URL = '/static/images/globe/satellite-icon.svg';
function init() {
initSkyRenderer();
const initPromise = initSkyRenderer();
if (initPromise && typeof initPromise.then === 'function') {
initPromise.then(() => {
if (lastSky) drawSkyView(lastSky.satellites || []);
else drawEmptySkyView();
}).catch(() => {});
}
drawEmptySkyView();
if (!connected) connect();
@@ -48,26 +71,397 @@ const GPS = (function() {
}
function initSkyRenderer() {
if (skyRendererInitAttempted) return;
if (skyRendererInitPromise) return skyRendererInitPromise;
skyRendererInitAttempted = true;
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
let fallbackRenderer = null;
const fallbackCanvas = document.getElementById('gpsSkyCanvas');
const fallbackOverlay = document.getElementById('gpsSkyOverlay');
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);
// Show an immediate fallback while the globe library loads.
setSkyCanvasFallbackMode(true);
if (fallbackCanvas) {
try {
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
skyRenderer = fallbackRenderer;
} catch (err) {
fallbackRenderer = null;
skyRenderer = null;
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
}
}
skyRendererInitPromise = (async function() {
const globeContainer = document.getElementById('gpsSkyGlobe');
if (globeContainer) {
try {
const globeRenderer = await createGlobeSkyRenderer(globeContainer);
if (globeRenderer) {
if (fallbackRenderer && fallbackRenderer !== globeRenderer && typeof fallbackRenderer.destroy === 'function') {
fallbackRenderer.destroy();
}
setSkyCanvasFallbackMode(false);
skyRenderer = globeRenderer;
return skyRenderer;
}
} catch (err) {
console.warn('GPS globe renderer failed, falling back to canvas renderer', err);
}
}
setSkyCanvasFallbackMode(true);
if (!fallbackRenderer && fallbackCanvas) {
try {
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
} catch (err) {
fallbackRenderer = null;
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
}
}
skyRenderer = fallbackRenderer;
return skyRenderer;
})();
return skyRendererInitPromise;
}
function setSkyCanvasFallbackMode(enabled) {
const wrap = document.getElementById('gpsSkyViewWrap');
if (wrap) {
wrap.classList.toggle('gps-sky-fallback', !!enabled);
}
}
function connect() {
updateConnectionUI(false, false, 'connecting');
fetch('/gps/auto-connect', { method: 'POST' })
.then(r => r.json())
.then(data => {
function isSkyCanvasFallbackEnabled() {
const wrap = document.getElementById('gpsSkyViewWrap');
return !wrap || wrap.classList.contains('gps-sky-fallback');
}
function getObserverCoords() {
const posLat = Number(lastPosition && lastPosition.latitude);
const posLon = Number(lastPosition && lastPosition.longitude);
if (Number.isFinite(posLat) && Number.isFinite(posLon)) {
return { lat: posLat, lon: normalizeLon(posLon) };
}
if (typeof observerLocation === 'object' && observerLocation) {
const obsLat = Number(observerLocation.lat);
const obsLon = Number(observerLocation.lon);
if (Number.isFinite(obsLat) && Number.isFinite(obsLon)) {
return { lat: obsLat, lon: normalizeLon(obsLon) };
}
}
return null;
}
async function ensureGpsGlobeLibrary() {
if (typeof window.Globe === 'function') return true;
const webglSupportFn = (typeof isWebglSupported === 'function') ? isWebglSupported : localWebglSupportCheck;
if (!webglSupportFn()) return false;
if (typeof ensureWebsdrGlobeLibrary === 'function') {
try {
const ready = await ensureWebsdrGlobeLibrary();
if (ready && typeof window.Globe === 'function') return true;
} catch (_) {}
}
for (const src of GPS_GLOBE_SCRIPT_URLS) {
await loadGpsGlobeScript(src);
}
return typeof window.Globe === 'function';
}
function loadGpsGlobeScript(src) {
const state = getSharedGlobeScriptState();
if (!state.promises[src]) {
state.promises[src] = loadSharedGlobeScript(src);
}
return state.promises[src].catch((error) => {
delete state.promises[src];
throw error;
});
}
function getSharedGlobeScriptState() {
const key = '__interceptGlobeScriptState';
if (!window[key]) {
window[key] = {
promises: Object.create(null),
};
}
return window[key];
}
function loadSharedGlobeScript(src) {
return new Promise((resolve, reject) => {
const selector = [
`script[data-intercept-globe-src="${src}"]`,
`script[data-websdr-src="${src}"]`,
`script[data-gps-globe-src="${src}"]`,
`script[src="${src}"]`,
].join(', ');
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.interceptGlobeSrc = src;
script.dataset.gpsGlobeSrc = 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 localWebglSupportCheck() {
try {
const canvas = document.createElement('canvas');
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
} catch (_) {
return false;
}
}
async function createGlobeSkyRenderer(container) {
const ready = await ensureGpsGlobeLibrary();
if (!ready || typeof window.Globe !== 'function') return null;
let layoutAttempts = 0;
while ((!container.clientWidth || !container.clientHeight) && layoutAttempts < 4) {
await new Promise(resolve => requestAnimationFrame(resolve));
layoutAttempts += 1;
}
if (!container.clientWidth || !container.clientHeight) return null;
container.innerHTML = '';
container.style.background = 'radial-gradient(circle at 32% 18%, rgba(16, 45, 70, 0.92), rgba(4, 9, 16, 0.96) 58%, rgba(2, 4, 9, 0.99) 100%)';
container.style.cursor = 'grab';
const globe = window.Globe()(container)
.backgroundColor('rgba(0,0,0,0)')
.globeImageUrl(GPS_GLOBE_TEXTURE_URL)
.showAtmosphere(true)
.atmosphereColor('#3bb9ff')
.atmosphereAltitude(0.17)
.pointRadius('radius')
.pointAltitude('altitude')
.pointColor('color')
.pointLabel(point => point.label || '')
.pointsTransitionDuration(0)
.htmlAltitude('altitude')
.htmlElementsData([])
.htmlElement((sat) => createSatelliteIconElement(sat));
const controls = globe.controls();
if (controls) {
controls.autoRotate = false;
controls.enablePan = false;
controls.minDistance = 130;
controls.maxDistance = 420;
controls.rotateSpeed = 0.8;
controls.zoomSpeed = 0.8;
}
let destroyed = false;
let lastSatellites = [];
let hasInitialView = false;
const resizeObserver = (typeof ResizeObserver !== 'undefined')
? new ResizeObserver(() => resizeGlobe())
: null;
if (resizeObserver) resizeObserver.observe(container);
function resizeGlobe() {
if (destroyed) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) return;
globe.width(width);
globe.height(height);
}
function renderGlobe() {
if (destroyed) return;
resizeGlobe();
const observer = getObserverCoords();
const points = [];
const satelliteIcons = [];
if (observer) {
points.push({
lat: observer.lat,
lng: observer.lon,
altitude: 0.012,
radius: 0.34,
color: '#ffffff',
label: '<div style="padding:4px 6px; font-size:11px; background:rgba(5,13,20,0.92); border:1px solid rgba(255,255,255,0.28); border-radius:4px;">Observer</div>',
});
}
lastSatellites.forEach((sat) => {
const azimuth = Number(sat.azimuth);
const elevation = Number(sat.elevation);
if (!observer || !Number.isFinite(azimuth) || !Number.isFinite(elevation)) return;
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
const shellAltitude = getSatelliteShellAltitude(sat.constellation, elevation);
const footprint = projectSkyTrackToEarth(observer.lat, observer.lon, azimuth, elevation);
satelliteIcons.push({
lat: footprint.lat,
lng: footprint.lon,
altitude: shellAltitude,
color: color,
used: !!sat.used,
sizePx: sat.used ? 20 : 17,
title: buildSatelliteTitle(sat),
iconUrl: GPS_SATELLITE_ICON_URL,
});
});
globe.pointsData(points);
globe.htmlElementsData(satelliteIcons);
if (observer && !hasInitialView) {
globe.pointOfView({ lat: observer.lat, lng: observer.lon, altitude: 1.6 }, 950);
hasInitialView = true;
}
}
function createSatelliteIconElement(sat) {
const marker = document.createElement('div');
marker.className = `gps-globe-sat-icon ${sat.used ? 'used' : 'unused'}`;
marker.style.setProperty('--sat-color', sat.color || '#9fb2c5');
marker.style.setProperty('--sat-size', `${Math.max(12, Number(sat.sizePx) || 18)}px`);
marker.title = sat.title || 'Satellite';
const img = document.createElement('img');
img.src = sat.iconUrl || GPS_SATELLITE_ICON_URL;
img.alt = 'Satellite';
img.decoding = 'async';
img.draggable = false;
marker.appendChild(img);
return marker;
}
function setSatellites(satellites) {
lastSatellites = Array.isArray(satellites) ? satellites : [];
renderGlobe();
}
function requestRender() {
renderGlobe();
}
function destroy() {
destroyed = true;
if (resizeObserver) {
try {
resizeObserver.disconnect();
} catch (_) {}
}
container.innerHTML = '';
}
setSatellites([]);
return {
setSatellites: setSatellites,
requestRender: requestRender,
destroy: destroy,
};
}
function buildSatelliteTitle(sat) {
const constellation = String(sat.constellation || 'GPS');
const prn = String(sat.prn || '--');
const elevation = Number.isFinite(Number(sat.elevation)) ? `${Number(sat.elevation).toFixed(1)}\u00b0` : '--';
const azimuth = Number.isFinite(Number(sat.azimuth)) ? `${Number(sat.azimuth).toFixed(1)}\u00b0` : '--';
const snr = Number.isFinite(Number(sat.snr)) ? `${Math.round(Number(sat.snr))} dB-Hz` : 'n/a';
const used = sat.used ? 'USED IN FIX' : 'TRACKED';
return `${constellation} PRN ${prn} | El ${elevation} | Az ${azimuth} | SNR ${snr} | ${used}`;
}
function getSatelliteShellAltitude(constellation, elevation) {
const base = CONST_ALTITUDES[constellation] || CONST_ALTITUDES.GPS;
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
const horizonFactor = 1 - (el / 90);
return base + (horizonFactor * 0.04);
}
function projectSkyTrackToEarth(observerLat, observerLon, azimuth, elevation) {
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
const horizonFactor = 1 - (el / 90);
const angularDistance = 76 * Math.pow(horizonFactor, 1.08);
return destinationPoint(observerLat, observerLon, azimuth, angularDistance);
}
function destinationPoint(latDeg, lonDeg, bearingDeg, distanceDeg) {
const lat1 = degToRad(latDeg);
const lon1 = degToRad(lonDeg);
const bearing = degToRad(bearingDeg);
const distance = degToRad(distanceDeg);
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinDist = Math.sin(distance);
const cosDist = Math.cos(distance);
const sinLat2 = (sinLat1 * cosDist) + (cosLat1 * sinDist * Math.cos(bearing));
const lat2 = Math.asin(Math.max(-1, Math.min(1, sinLat2)));
const y = Math.sin(bearing) * sinDist * cosLat1;
const x = cosDist - (sinLat1 * Math.sin(lat2));
const lon2 = lon1 + Math.atan2(y, x);
return {
lat: radToDeg(lat2),
lon: normalizeLon(radToDeg(lon2)),
};
}
function normalizeLon(lon) {
let normalized = (lon + 540) % 360;
normalized = normalized < 0 ? normalized + 360 : normalized;
return normalized - 180;
}
function radToDeg(rad) {
return rad * 180 / Math.PI;
}
function connect() {
updateConnectionUI(false, false, 'connecting');
fetch('/gps/auto-connect', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'connected') {
connected = true;
updateConnectionUI(true, data.has_fix);
@@ -78,16 +472,18 @@ const GPS = (function() {
if (data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
subscribeToStream();
startSkyPolling();
// Ensure the global GPS stream is running
if (typeof startGpsStream === 'function' && !gpsEventSource) {
startGpsStream();
}
} else {
connected = false;
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
}
subscribeToStream();
startSkyPolling();
startStatusPolling();
// Ensure the global GPS stream is running
const hasGlobalGpsStream = typeof gpsEventSource !== 'undefined' && !!gpsEventSource;
if (typeof startGpsStream === 'function' && !hasGlobalGpsStream) {
startGpsStream();
}
} else {
connected = false;
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
}
})
.catch(() => {
@@ -96,36 +492,40 @@ const GPS = (function() {
});
}
function disconnect() {
unsubscribeFromStream();
stopSkyPolling();
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
function disconnect() {
unsubscribeFromStream();
stopSkyPolling();
stopStatusPolling();
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
}
function onGpsStreamData(data) {
if (!connected) return;
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
}
function startSkyPolling() {
stopSkyPolling();
// Poll satellite data every 5 seconds as a reliable fallback
// SSE stream may miss sky updates due to queue contention with position messages
pollSatellites();
skyPollTimer = setInterval(pollSatellites, 5000);
}
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
if (lastSky && skyRenderer) {
drawSkyView(lastSky.satellites || []);
}
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
}
function startSkyPolling() {
stopSkyPolling();
// Poll satellite data every 5 seconds as a reliable fallback
// SSE stream may miss sky updates due to queue contention with position messages
pollSatellites();
skyPollTimer = setInterval(pollSatellites, 5000);
}
function stopSkyPolling() {
if (skyPollTimer) {
clearInterval(skyPollTimer);
@@ -133,18 +533,62 @@ const GPS = (function() {
}
}
function pollSatellites() {
if (!connected) return;
fetch('/gps/satellites')
.then(r => r.json())
.then(data => {
function pollSatellites() {
if (!connected) return;
fetch('/gps/satellites')
.then(r => r.json())
.then(data => {
if (data.status === 'ok' && data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
})
.catch(() => {});
}
})
.catch(() => {});
}
function startStatusPolling() {
stopStatusPolling();
// Poll full status as a fallback when SSE is unavailable or blocked.
pollStatus();
statusPollTimer = setInterval(pollStatus, 2000);
}
function stopStatusPolling() {
if (statusPollTimer) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
function pollStatus() {
if (!connected) return;
fetch('/gps/status')
.then(r => r.json())
.then(data => {
if (!connected || !data) return;
if (data.running !== true) {
connected = false;
stopSkyPolling();
stopStatusPolling();
updateConnectionUI(false, false, 'error', data.message || 'GPS disconnected');
return;
}
if (data.position) {
lastPosition = data.position;
updatePositionUI(data.position);
updateConnectionUI(true, true);
} else {
updateConnectionUI(true, false);
}
if (data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
})
.catch(() => {});
}
function subscribeToStream() {
// Subscribe to the global GPS stream instead of opening a separate SSE connection
@@ -294,8 +738,11 @@ const GPS = (function() {
return;
}
if (!isSkyCanvasFallbackEnabled()) return;
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
resize2DFallbackCanvas(canvas);
drawSkyViewBase2D(canvas);
}
@@ -311,9 +758,12 @@ const GPS = (function() {
return;
}
if (!isSkyCanvasFallbackEnabled()) return;
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
resize2DFallbackCanvas(canvas);
drawSkyViewBase2D(canvas);
const ctx = canvas.getContext('2d');
@@ -428,6 +878,15 @@ const GPS = (function() {
ctx.fill();
}
function resize2DFallbackCanvas(canvas) {
const cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
const cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
if (canvas.width !== cssWidth || canvas.height !== cssHeight) {
canvas.width = cssWidth;
canvas.height = cssHeight;
}
}
function createWebGlSkyRenderer(canvas, overlay) {
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
if (!gl) return null;
@@ -1076,6 +1535,7 @@ const GPS = (function() {
function destroy() {
unsubscribeFromStream();
stopSkyPolling();
stopStatusPolling();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
@@ -1085,6 +1545,8 @@ const GPS = (function() {
skyRenderer = null;
}
skyRendererInitAttempted = false;
skyRendererInitPromise = null;
setSkyCanvasFallbackMode(false);
}
return {

400
static/js/modes/morse.js Normal file
View File

@@ -0,0 +1,400 @@
/**
* Morse Code (CW) decoder module.
*
* IIFE providing start/stop controls, SSE streaming, scope canvas,
* decoded text display, and export capabilities.
*/
var MorseMode = (function () {
'use strict';
var state = {
running: false,
initialized: false,
eventSource: null,
charCount: 0,
decodedLog: [], // { timestamp, morse, char }
};
// Scope state
var scopeCtx = null;
var scopeAnim = null;
var scopeHistory = [];
var SCOPE_HISTORY_LEN = 300;
var scopeThreshold = 0;
var scopeToneOn = false;
// ---- Initialization ----
function init() {
if (state.initialized) {
checkStatus();
return;
}
state.initialized = true;
checkStatus();
}
function destroy() {
disconnectSSE();
stopScope();
}
// ---- Status ----
function checkStatus() {
fetch('/morse/status')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.running) {
state.running = true;
updateUI(true);
connectSSE();
startScope();
} else {
state.running = false;
updateUI(false);
}
})
.catch(function () {});
}
// ---- Start / Stop ----
function start() {
if (state.running) return;
var payload = {
frequency: document.getElementById('morseFrequency').value || '14.060',
gain: document.getElementById('morseGain').value || '0',
ppm: document.getElementById('morsePPM').value || '0',
device: document.getElementById('deviceSelect')?.value || '0',
sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr',
tone_freq: document.getElementById('morseToneFreq').value || '700',
wpm: document.getElementById('morseWpm').value || '15',
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
};
fetch('/morse/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'started') {
state.running = true;
state.charCount = 0;
state.decodedLog = [];
updateUI(true);
connectSSE();
startScope();
clearDecodedText();
} else {
alert('Error: ' + (data.message || 'Unknown error'));
}
})
.catch(function (err) {
alert('Failed to start Morse decoder: ' + err);
});
}
function stop() {
fetch('/morse/stop', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function () {
state.running = false;
updateUI(false);
disconnectSSE();
stopScope();
})
.catch(function () {});
}
// ---- SSE ----
function connectSSE() {
disconnectSSE();
var es = new EventSource('/morse/stream');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
handleMessage(msg);
} catch (_) {}
};
es.onerror = function () {
// Reconnect handled by browser
};
state.eventSource = es;
}
function disconnectSSE() {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}
function handleMessage(msg) {
var type = msg.type;
if (type === 'scope') {
// Update scope data
var amps = msg.amplitudes || [];
for (var i = 0; i < amps.length; i++) {
scopeHistory.push(amps[i]);
if (scopeHistory.length > SCOPE_HISTORY_LEN) {
scopeHistory.shift();
}
}
scopeThreshold = msg.threshold || 0;
scopeToneOn = msg.tone_on || false;
} else if (type === 'morse_char') {
appendChar(msg.char, msg.morse, msg.timestamp);
} else if (type === 'morse_space') {
appendSpace();
} else if (type === 'status') {
if (msg.status === 'stopped') {
state.running = false;
updateUI(false);
disconnectSSE();
stopScope();
}
} else if (type === 'error') {
console.error('Morse error:', msg.text);
}
}
// ---- Decoded text ----
function appendChar(ch, morse, timestamp) {
state.charCount++;
state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch });
var panel = document.getElementById('morseDecodedText');
if (!panel) return;
var span = document.createElement('span');
span.className = 'morse-char';
span.textContent = ch;
span.title = morse + ' (' + timestamp + ')';
panel.appendChild(span);
// Auto-scroll
panel.scrollTop = panel.scrollHeight;
// Update count
var countEl = document.getElementById('morseCharCount');
if (countEl) countEl.textContent = state.charCount + ' chars';
var barChars = document.getElementById('morseStatusBarChars');
if (barChars) barChars.textContent = state.charCount + ' chars decoded';
}
function appendSpace() {
var panel = document.getElementById('morseDecodedText');
if (!panel) return;
var span = document.createElement('span');
span.className = 'morse-word-space';
span.textContent = ' ';
panel.appendChild(span);
}
function clearDecodedText() {
var panel = document.getElementById('morseDecodedText');
if (panel) panel.innerHTML = '';
state.charCount = 0;
state.decodedLog = [];
var countEl = document.getElementById('morseCharCount');
if (countEl) countEl.textContent = '0 chars';
var barChars = document.getElementById('morseStatusBarChars');
if (barChars) barChars.textContent = '0 chars decoded';
}
// ---- Scope canvas ----
function startScope() {
var canvas = document.getElementById('morseScopeCanvas');
if (!canvas) return;
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 80 * dpr;
canvas.style.height = '80px';
scopeCtx = canvas.getContext('2d');
scopeCtx.scale(dpr, dpr);
scopeHistory = [];
var toneLabel = document.getElementById('morseScopeToneLabel');
var threshLabel = document.getElementById('morseScopeThreshLabel');
function draw() {
if (!scopeCtx) return;
var w = rect.width;
var h = 80;
scopeCtx.fillStyle = '#050510';
scopeCtx.fillRect(0, 0, w, h);
// Update header labels
if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--';
if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--';
if (scopeHistory.length === 0) {
scopeAnim = requestAnimationFrame(draw);
return;
}
// Find max for normalization
var maxVal = 0;
for (var i = 0; i < scopeHistory.length; i++) {
if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i];
}
if (maxVal === 0) maxVal = 1;
var barW = w / SCOPE_HISTORY_LEN;
var threshNorm = scopeThreshold / maxVal;
// Draw amplitude bars
for (var j = 0; j < scopeHistory.length; j++) {
var norm = scopeHistory[j] / maxVal;
var barH = norm * (h - 10);
var x = j * barW;
var y = h - barH;
// Green if above threshold, gray if below
if (scopeHistory[j] > scopeThreshold) {
scopeCtx.fillStyle = '#00ff88';
} else {
scopeCtx.fillStyle = '#334455';
}
scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH);
}
// Draw threshold line
if (scopeThreshold > 0) {
var threshY = h - (threshNorm * (h - 10));
scopeCtx.strokeStyle = '#ff4444';
scopeCtx.lineWidth = 1;
scopeCtx.setLineDash([4, 4]);
scopeCtx.beginPath();
scopeCtx.moveTo(0, threshY);
scopeCtx.lineTo(w, threshY);
scopeCtx.stroke();
scopeCtx.setLineDash([]);
}
// Tone indicator
if (scopeToneOn) {
scopeCtx.fillStyle = '#00ff88';
scopeCtx.beginPath();
scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2);
scopeCtx.fill();
}
scopeAnim = requestAnimationFrame(draw);
}
draw();
}
function stopScope() {
if (scopeAnim) {
cancelAnimationFrame(scopeAnim);
scopeAnim = null;
}
scopeCtx = null;
}
// ---- Export ----
function exportTxt() {
var text = state.decodedLog.map(function (e) { return e.char; }).join('');
downloadFile('morse_decoded.txt', text, 'text/plain');
}
function exportCsv() {
var lines = ['timestamp,morse,character'];
state.decodedLog.forEach(function (e) {
lines.push(e.timestamp + ',"' + e.morse + '",' + e.char);
});
downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv');
}
function copyToClipboard() {
var text = state.decodedLog.map(function (e) { return e.char; }).join('');
navigator.clipboard.writeText(text).then(function () {
var btn = document.getElementById('morseCopyBtn');
if (btn) {
var orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function () { btn.textContent = orig; }, 1500);
}
});
}
function downloadFile(filename, content, type) {
var blob = new Blob([content], { type: type });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// ---- UI ----
function updateUI(running) {
var startBtn = document.getElementById('morseStartBtn');
var stopBtn = document.getElementById('morseStopBtn');
var indicator = document.getElementById('morseStatusIndicator');
var statusText = document.getElementById('morseStatusText');
if (startBtn) startBtn.style.display = running ? 'none' : '';
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
if (indicator) {
indicator.style.background = running ? '#00ff88' : 'var(--text-dim)';
}
if (statusText) {
statusText.textContent = running ? 'Listening' : 'Standby';
}
// Toggle scope and output panels (pager/sensor pattern)
var scopePanel = document.getElementById('morseScopePanel');
var outputPanel = document.getElementById('morseOutputPanel');
if (scopePanel) scopePanel.style.display = running ? 'block' : 'none';
if (outputPanel) outputPanel.style.display = running ? 'block' : 'none';
var scopeStatus = document.getElementById('morseScopeStatusLabel');
if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE';
if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444';
}
function setFreq(mhz) {
var el = document.getElementById('morseFrequency');
if (el) el.value = mhz;
}
// ---- Public API ----
return {
init: init,
destroy: destroy,
start: start,
stop: stop,
setFreq: setFreq,
exportTxt: exportTxt,
exportCsv: exportCsv,
copyToClipboard: copyToClipboard,
clearText: clearDecodedText,
};
})();

View File

@@ -36,6 +36,7 @@ const Waterfall = (function () {
let _startMhz = 98.8;
let _endMhz = 101.2;
let _lastEffectiveSpan = 2.4;
let _monitorFreqMhz = 100.0;
let _monitoring = false;
@@ -2515,6 +2516,11 @@ const Waterfall = (function () {
_endMhz = msg.end_freq;
_drawFreqAxis();
}
if (Number.isFinite(msg.effective_span_mhz)) {
_lastEffectiveSpan = msg.effective_span_mhz;
const spanEl = document.getElementById('wfSpanMhz');
if (spanEl) spanEl.value = msg.effective_span_mhz;
}
_setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`);
_setVisualStatus('RUNNING');
if (_monitoring) {
@@ -2535,6 +2541,12 @@ const Waterfall = (function () {
}
_updateFreqDisplay();
_setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`);
if (_monitoring && _monitorSource === 'waterfall') {
const mode = _getMonitorMode().toUpperCase();
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${mode} via shared IQ`);
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${mode})`);
_setVisualStatus('MONITOR');
}
if (!_monitoring) _setVisualStatus('RUNNING');
} else if (_onRetuneRequired(msg)) {
return;
@@ -2557,6 +2569,10 @@ const Waterfall = (function () {
_pendingMonitorTuneMhz = null;
_scanStartPending = false;
_pendingSharedMonitorRearm = false;
// Reset span input to last known good value so an
// invalid span doesn't persist across restart (#150).
const spanEl = document.getElementById('wfSpanMhz');
if (spanEl) spanEl.value = _lastEffectiveSpan;
// If the monitor was using the shared IQ stream that
// just failed, tear down the stale monitor state so
// the button becomes clickable again after restart.
@@ -2603,7 +2619,7 @@ const Waterfall = (function () {
player.load();
}
async function _attachMonitorAudio(nonce) {
async function _attachMonitorAudio(nonce, streamToken = null) {
const player = document.getElementById('wfAudioPlayer');
if (!player) {
return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' };
@@ -2622,7 +2638,10 @@ const Waterfall = (function () {
}
await _pauseMonitorAudioElement();
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`;
const tokenQuery = (streamToken !== null && streamToken !== undefined && String(streamToken).length > 0)
? `&request_token=${encodeURIComponent(String(streamToken))}`
: '';
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}${tokenQuery}`;
player.load();
try {
@@ -2678,25 +2697,6 @@ const Waterfall = (function () {
};
}
function _deviceKey(device) {
if (!device) return '';
return `${device.sdrType || ''}:${device.deviceIndex || 0}`;
}
function _findAlternateDevice(currentDevice) {
const currentKey = _deviceKey(currentDevice);
for (const d of _devices) {
const candidate = {
sdrType: String(d.sdr_type || 'rtlsdr'),
deviceIndex: parseInt(d.index, 10) || 0,
};
if (_deviceKey(candidate) !== currentKey) {
return candidate;
}
}
return null;
}
async function _requestAudioStart({
frequency,
modulation,
@@ -2760,6 +2760,7 @@ const Waterfall = (function () {
_resumeWaterfallAfterMonitor = !!wasRunningWaterfall;
}
const liveCenterMhz = _currentCenter();
// Keep an explicit pending tune target so retunes cannot fall
// back to a stale frequency during capture restart churn.
const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz)
@@ -2767,11 +2768,11 @@ const Waterfall = (function () {
: (
Number.isFinite(_pendingCaptureVfoMhz)
? _pendingCaptureVfoMhz
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter())
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : liveCenterMhz)
);
const centerMhz = retuneOnly
? (Number.isFinite(requestedTuneMhz) ? requestedTuneMhz : _currentCenter())
: _currentCenter();
? (Number.isFinite(liveCenterMhz) ? liveCenterMhz : requestedTuneMhz)
: liveCenterMhz;
const mode = document.getElementById('wfMonitorMode')?.value || 'wfm';
const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0;
const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10);
@@ -2780,69 +2781,98 @@ const Waterfall = (function () {
? sliderGain
: (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40);
const selectedDevice = _selectedDevice();
const altDevice = _running ? _findAlternateDevice(selectedDevice) : null;
let monitorDevice = altDevice || selectedDevice;
// Always target the currently selected SDR for monitor start/retune.
// This keeps waterfall-shared monitor tuning deterministic and avoids
// retuning a different receiver than the one driving the display.
let monitorDevice = selectedDevice;
const biasT = !!document.getElementById('wfBiasT')?.checked;
const usingSecondaryDevice = !!altDevice;
// Use a high monotonic token so backend start ordering remains
// valid across page reloads (local nonces reset to small values).
const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff));
if (!retuneOnly) {
_monitorFreqMhz = centerMhz;
} else if (Number.isFinite(centerMhz)) {
_monitorFreqMhz = centerMhz;
_pendingMonitorTuneMhz = centerMhz;
_pendingCaptureVfoMhz = centerMhz;
}
_drawFreqAxis();
_stopSmeter();
_setUnlockVisible(false);
_audioUnlockRequired = false;
if (usingSecondaryDevice) {
if (retuneOnly && _monitoring) {
_setMonitorState(`Retuning ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
} else {
_setMonitorState(
`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on `
+ `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...`
);
} else {
_setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
}
// Use live _monitorFreqMhz for retunes so that any user
// clicks that changed the VFO during the async setup are
// picked up rather than overridden.
let { response, payload } = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: monitorDevice,
biasT,
requestToken: nonce,
});
const requestAudioStartResynced = async (deviceForRequest) => {
let startResult = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: deviceForRequest,
biasT,
requestToken,
});
const startPayload = startResult?.payload || {};
const isStale = startPayload.superseded === true || startPayload.status === 'stale';
if (isStale) {
const currentToken = Number(startPayload.current_token);
if (Number.isFinite(currentToken) && currentToken >= 0) {
startResult = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: deviceForRequest,
biasT,
requestToken: currentToken + 1,
});
}
}
return startResult;
};
let { response, payload } = await requestAudioStartResynced(monitorDevice);
if (nonce !== _audioConnectNonce) return;
const staleStart = payload?.superseded === true || payload?.status === 'stale';
if (staleStart) return;
if (staleStart) {
// If the backend still reports stale after token resync,
// schedule a fresh retune so monitor audio does not stay on
// an older station indefinitely.
if (_monitoring) {
const liveMode = _getMonitorMode().toUpperCase();
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${liveMode}`);
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${liveMode})`);
_setVisualStatus('MONITOR');
_queueMonitorRetune(90);
}
return;
}
const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart);
if (
busy
&& _running
&& !usingSecondaryDevice
&& !retuneOnly
) {
if (busy && _running && !retuneOnly) {
_setMonitorState('Audio device busy, pausing waterfall and retrying monitor...');
await stop({ keepStatus: true });
_resumeWaterfallAfterMonitor = true;
await _wait(220);
monitorDevice = selectedDevice;
({ response, payload } = await _requestAudioStart({
frequency: centerMhz,
modulation: mode,
squelch,
gain,
device: monitorDevice,
biasT,
requestToken: nonce,
}));
({ response, payload } = await requestAudioStartResynced(monitorDevice));
if (nonce !== _audioConnectNonce) return;
if (payload?.superseded === true || payload?.status === 'stale') return;
if (payload?.superseded === true || payload?.status === 'stale') {
if (_monitoring) _queueMonitorRetune(90);
return;
}
}
if (!response.ok || payload.status !== 'started') {
@@ -2861,13 +2891,14 @@ const Waterfall = (function () {
return;
}
const attach = await _attachMonitorAudio(nonce);
const attach = await _attachMonitorAudio(nonce, payload?.request_token);
if (nonce !== _audioConnectNonce) return;
_monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process';
if (
const pendingTuneMismatch = (
Number.isFinite(_pendingMonitorTuneMhz)
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) < 1e-6
) {
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) >= 1e-6
);
if (!pendingTuneMismatch) {
_pendingMonitorTuneMhz = null;
}
@@ -2878,6 +2909,7 @@ const Waterfall = (function () {
_setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`);
_setStatus('Monitor started but browser blocked playback. Click Unlock Audio.');
_setVisualStatus('MONITOR');
if (pendingTuneMismatch) _queueMonitorRetune(45);
return;
}
@@ -2911,20 +2943,27 @@ const Waterfall = (function () {
_setMonitorState(
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ`
);
} else if (usingSecondaryDevice) {
} else {
_setMonitorState(
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} `
+ `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}`
);
} else {
_setMonitorState(`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()}`);
}
_setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`);
_setVisualStatus('MONITOR');
if (pendingTuneMismatch) {
_queueMonitorRetune(45);
}
// After a retune reconnect, sync the backend to the latest
// VFO in case the user clicked a new frequency while the
// audio stream was reconnecting.
if (retuneOnly && _monitorSource === 'waterfall' && _ws && _ws.readyState === WebSocket.OPEN) {
if (
!pendingTuneMismatch
&& retuneOnly
&& _monitorSource === 'waterfall'
&& _ws
&& _ws.readyState === WebSocket.OPEN
) {
_sendWsTuneCmd();
}
} catch (err) {
@@ -3233,7 +3272,8 @@ const Waterfall = (function () {
function stepFreq(multiplier) {
const step = _getNumber('wfStepSize', 0.1);
_setAndTune(_currentCenter() + multiplier * step, true);
// Coalesce rapid step-button presses into one final retune.
_setAndTune(_currentCenter() + multiplier * step, false);
}
function zoomBy(factor) {

View File

@@ -265,10 +265,13 @@ const WeatherSat = (function() {
* Stop capture
*/
async function stop() {
// Optimistically update UI immediately so stop feels responsive,
// even if the server takes time to terminate the process.
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopping...');
try {
await fetch('/weather-sat/stop', { method: 'POST' });
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('Weather Sat', 'Capture stopped');
} catch (err) {

View File

@@ -19,7 +19,6 @@ 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';
@@ -186,8 +185,34 @@ async function ensureWebsdrGlobeLibrary() {
}
function loadWebsdrScript(src) {
const state = getSharedGlobeScriptState();
if (!state.promises[src]) {
state.promises[src] = loadSharedGlobeScript(src);
}
return state.promises[src].catch((error) => {
delete state.promises[src];
throw error;
});
}
function getSharedGlobeScriptState() {
const key = '__interceptGlobeScriptState';
if (!window[key]) {
window[key] = {
promises: Object.create(null),
};
}
return window[key];
}
function loadSharedGlobeScript(src) {
return new Promise((resolve, reject) => {
const selector = `script[data-websdr-src="${src}"]`;
const selector = [
`script[data-intercept-globe-src="${src}"]`,
`script[data-websdr-src="${src}"]`,
`script[data-gps-globe-src="${src}"]`,
`script[src="${src}"]`,
].join(', ');
const existing = document.querySelector(selector);
if (existing) {
@@ -208,6 +233,7 @@ function loadWebsdrScript(src) {
script.src = src;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.interceptGlobeSrc = src;
script.dataset.websdrSrc = src;
script.onload = () => {
script.dataset.loaded = 'true';

1171
static/js/modes/wefax.js Normal file

File diff suppressed because it is too large Load Diff