diff --git a/routes/pager.py b/routes/pager.py index 8d6f59a..6dfcc3b 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None: return None -def log_message(msg: dict[str, Any]) -> None: +def log_message(msg: dict[str, Any]) -> None: """Log a message to file if logging is enabled.""" if not app_module.logging_enabled: return @@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None: with open(app_module.log_file_path, 'a') as f: timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") - except Exception as e: - logger.error(f"Failed to log message: {e}") - - -def audio_relay_thread( - rtl_stdout, - multimon_stdin, - output_queue: queue.Queue, - stop_event: threading.Event, -) -> None: - """Relay audio from rtl_fm to multimon-ng while computing signal levels. - - Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight - through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope - event onto *output_queue*. - """ - CHUNK = 4096 # bytes – 2048 samples at 16-bit mono - INTERVAL = 0.1 # seconds between scope updates - last_scope = time.monotonic() + except Exception as e: + logger.error(f"Failed to log message: {e}") + + +def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: + """Compress recent PCM samples into a signed 8-bit waveform for SSE.""" + if not samples: + return [] + + window = samples[-window_size:] if len(samples) > window_size else samples + waveform: list[int] = [] + for sample in window: + # Convert int16 PCM to int8 range for lightweight transport. + packed = int(round(sample / 256)) + waveform.append(max(-127, min(127, packed))) + return waveform + + +def audio_relay_thread( + rtl_stdout, + multimon_stdin, + output_queue: queue.Queue, + stop_event: threading.Event, +) -> None: + """Relay audio from rtl_fm to multimon-ng while computing signal levels. + + Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight + through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope + event plus a compact waveform sample onto *output_queue*. + """ + CHUNK = 4096 # bytes – 2048 samples at 16-bit mono + INTERVAL = 0.1 # seconds between scope updates + last_scope = time.monotonic() try: while not stop_event.is_set(): @@ -146,15 +160,16 @@ def audio_relay_thread( if n_samples == 0: continue samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) - peak = max(abs(s) for s in samples) - rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) - output_queue.put_nowait({ - 'type': 'scope', - 'rms': rms, - 'peak': peak, - }) - except (struct.error, ValueError, queue.Full): - pass + peak = max(abs(s) for s in samples) + rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) + output_queue.put_nowait({ + 'type': 'scope', + 'rms': rms, + 'peak': peak, + 'waveform': _encode_scope_waveform(samples), + }) + except (struct.error, ValueError, queue.Full): + pass except Exception as e: logger.debug(f"Audio relay error: {e}") finally: diff --git a/routes/sensor.py b/routes/sensor.py index 9faf8e9..ab34c8e 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -1,14 +1,15 @@ """RTL_433 sensor monitoring routes.""" -from __future__ import annotations - -import json -import queue -import subprocess -import threading -import time -from datetime import datetime -from typing import Generator +from __future__ import annotations + +import json +import math +import queue +import subprocess +import threading +import time +from datetime import datetime +from typing import Any, Generator from flask import Blueprint, jsonify, request, Response @@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__) # Track which device is being used sensor_active_device: int | None = None -# RSSI history per device (model_id -> list of (timestamp, rssi)) -sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} -_MAX_RSSI_HISTORY = 60 - - -def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: +# RSSI history per device (model_id -> list of (timestamp, rssi)) +sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} +_MAX_RSSI_HISTORY = 60 + + +def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]: + """Synthesize a compact waveform from rtl_433 level metrics.""" + points = max(32, min(points, 512)) + + # rssi is usually negative; stronger signals are closer to 0 dBm. + rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0) + snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0) + noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0) + + amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm))) + cycles = 3.0 + (snr_norm * 8.0) + harmonic = 0.25 + (0.35 * snr_norm) + hiss = 0.08 + (0.18 * noise_norm) + phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi) + + waveform: list[int] = [] + for i in range(points): + t = i / (points - 1) + base = math.sin((2.0 * math.pi * cycles * t) + phase) + overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7)) + noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1)) + + sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble)) + sample /= (1.0 + harmonic + hiss) + packed = int(round(max(-1.0, min(1.0, sample)) * 127.0)) + waveform.append(max(-127, min(127, packed))) + + return waveform + + +def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: """Stream rtl_433 JSON output to queue.""" try: app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) @@ -64,16 +95,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: rssi = data.get('rssi') snr = data.get('snr') noise = data.get('noise') - if rssi is not None or snr is not None: - try: - app_module.sensor_queue.put_nowait({ - 'type': 'scope', - 'rssi': rssi if rssi is not None else 0, - 'snr': snr if snr is not None else 0, - 'noise': noise if noise is not None else 0, - }) - except queue.Full: - pass + if rssi is not None or snr is not None: + try: + rssi_value = float(rssi) if rssi is not None else 0.0 + snr_value = float(snr) if snr is not None else 0.0 + noise_value = float(noise) if noise is not None else 0.0 + app_module.sensor_queue.put_nowait({ + 'type': 'scope', + 'rssi': rssi_value, + 'snr': snr_value, + 'noise': noise_value, + 'waveform': _build_scope_waveform( + rssi=rssi_value, + snr=snr_value, + noise=noise_value, + ), + }) + except (TypeError, ValueError, queue.Full): + pass # Log if enabled if app_module.logging_enabled: diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 0dbcb43..b196714 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -898,66 +898,76 @@ body { inset: 0; pointer-events: none; overflow: hidden; - z-index: 650; - --target-x: 50%; - --target-y: 50%; + z-index: 1200; + --crosshair-x-start: 100%; + --crosshair-y-start: 100%; + --crosshair-x-end: 50%; + --crosshair-y-end: 50%; + --crosshair-duration: 1500ms; } .map-crosshair-line { position: absolute; opacity: 0; background: var(--accent-cyan); - box-shadow: 0 0 10px var(--accent-cyan); + box-shadow: none; + will-change: transform, opacity; } .map-crosshair-vertical { top: 0; bottom: 0; - width: 2px; - right: 0; - transform: translateX(0); + width: 1px; + left: 0; + transform: translateX(var(--crosshair-x-start)); } .map-crosshair-horizontal { left: 0; right: 0; - height: 2px; - bottom: 0; - transform: translateY(0); + height: 1px; + top: 0; + transform: translateY(var(--crosshair-y-start)); } .map-crosshair-overlay.active .map-crosshair-vertical { - animation: mapCrosshairSweepX 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards; + animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards; } .map-crosshair-overlay.active .map-crosshair-horizontal { - animation: mapCrosshairSweepY 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards; + animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards; } @keyframes mapCrosshairSweepX { 0% { - transform: translateX(0); - opacity: 0.95; + transform: translateX(var(--crosshair-x-start)); + opacity: 0; } - 75% { - opacity: 0.95; + 12% { + opacity: 1; + } + 85% { + opacity: 1; } 100% { - transform: translateX(calc(var(--target-x) - 100%)); + transform: translateX(var(--crosshair-x-end)); opacity: 0; } } @keyframes mapCrosshairSweepY { 0% { - transform: translateY(0); - opacity: 0.95; + transform: translateY(var(--crosshair-y-start)); + opacity: 0; } - 75% { - opacity: 0.95; + 12% { + opacity: 1; + } + 85% { + opacity: 1; } 100% { - transform: translateY(calc(var(--target-y) - 100%)); + transform: translateY(var(--crosshair-y-end)); opacity: 0; } } @@ -965,7 +975,7 @@ body { @media (prefers-reduced-motion: reduce) { .map-crosshair-overlay.active .map-crosshair-vertical, .map-crosshair-overlay.active .map-crosshair-horizontal { - animation-duration: 120ms; + animation-duration: 220ms; } } diff --git a/static/css/settings.css b/static/css/settings.css index 36f2987..c717b3c 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -479,10 +479,6 @@ filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05); } -.tile-layer-flir { - filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34); -} - /* Global Leaflet map theme: cyber overlay */ .leaflet-container.map-theme-cyber { position: relative; @@ -531,55 +527,6 @@ html.map-cyber-enabled .leaflet-container::after { background-size: 52px 52px, 52px 52px; } -/* Global Leaflet map theme: FLIR thermal overlay */ -.leaflet-container.map-theme-flir { - position: relative; - background: #090602; - isolation: isolate; -} - -.leaflet-container.map-theme-flir .leaflet-tile-pane { - filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34); - opacity: 1; -} - -/* Hard global fallback: enforce FLIR tint on all Leaflet tile images */ -html.map-flir-enabled .leaflet-container .leaflet-tile { - filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34) !important; -} - -/* Hard global fallback: thermal glow + scanline/grid overlay */ -html.map-flir-enabled .leaflet-container { - position: relative; - isolation: isolate; -} - -html.map-flir-enabled .leaflet-container::before { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - z-index: 620; - background: - radial-gradient(115% 90% at 50% 40%, rgba(255, 132, 28, 0.22), rgba(255, 132, 28, 0) 63%), - linear-gradient(180deg, rgba(14, 229, 255, 0.08) 0%, rgba(255, 96, 18, 0.15) 58%, rgba(255, 233, 128, 0.11) 100%); -} - -html.map-flir-enabled .leaflet-container::after { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - z-index: 621; - opacity: 0.32; - mix-blend-mode: screen; - background-image: - repeating-linear-gradient(0deg, rgba(255, 188, 92, 0.08) 0 1px, transparent 1px 3px), - linear-gradient(90deg, rgba(255, 141, 66, 0.12) 1px, transparent 1px), - linear-gradient(rgba(0, 240, 255, 0.07) 1px, transparent 1px); - background-size: 100% 3px, 68px 68px, 68px 68px; -} - /* Responsive */ @media (max-width: 960px) { .settings-tabs { diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 676c588..48edca2 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -33,15 +33,6 @@ const Settings = { mapTheme: 'cyber', options: {} }, - cartodb_dark_flir: { - url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', - attribution: '© OSM © CARTO', - subdomains: 'abcd', - mapTheme: 'flir', - options: { - className: 'tile-layer-flir' - } - }, cartodb_light: { url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', attribution: '© OSM © CARTO', @@ -116,7 +107,6 @@ const Settings = { const resolvedConfig = config || this.getTileConfig(); const themeClass = this._getMapThemeClass(resolvedConfig); document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber'); - document.documentElement.classList.toggle('map-flir-enabled', themeClass === 'map-theme-flir'); }, /** @@ -351,7 +341,6 @@ const Settings = { _getMapThemeClass(config) { if (!config || !config.mapTheme) return null; if (config.mapTheme === 'cyber') return 'map-theme-cyber'; - if (config.mapTheme === 'flir') return 'map-theme-flir'; return null; }, @@ -375,7 +364,7 @@ const Settings = { container.style.background = ''; } - container.classList.remove('map-theme-cyber', 'map-theme-flir'); + container.classList.remove('map-theme-cyber'); const resolvedConfig = config || this.getTileConfig(); const themeClass = this._getMapThemeClass(resolvedConfig); @@ -392,15 +381,6 @@ const Settings = { tilePane.style.opacity = '1'; tilePane.style.willChange = 'filter'; } - } else if (themeClass === 'map-theme-flir') { - if (container.style) { - container.style.background = '#090602'; - } - if (tilePane && tilePane.style) { - tilePane.style.filter = 'grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34)'; - tilePane.style.opacity = '1'; - tilePane.style.willChange = 'filter'; - } } // Map overlays are rendered via CSS pseudo elements on diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index 6613122..c16791d 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -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); diff --git a/static/sw.js b/static/sw.js index 283be7e..af523e5 100644 --- a/static/sw.js +++ b/static/sw.js @@ -17,21 +17,51 @@ const STATIC_PREFIXES = [ '/static/fonts/', ]; -const CACHE_EXACT = ['/manifest.json']; - -function isNetworkOnly(req) { - if (req.method !== 'GET') return true; - const accept = req.headers.get('Accept') || ''; - if (accept.includes('text/event-stream')) return true; - const url = new URL(req.url); +const CACHE_EXACT = ['/manifest.json']; + +function isHttpRequest(req) { + const url = new URL(req.url); + return url.protocol === 'http:' || url.protocol === 'https:'; +} + +function isNetworkOnly(req) { + if (req.method !== 'GET') return true; + const accept = req.headers.get('Accept') || ''; + if (accept.includes('text/event-stream')) return true; + const url = new URL(req.url); return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p)); } -function isStaticAsset(req) { - const url = new URL(req.url); - if (CACHE_EXACT.includes(url.pathname)) return true; - return STATIC_PREFIXES.some(p => url.pathname.startsWith(p)); -} +function isStaticAsset(req) { + const url = new URL(req.url); + if (CACHE_EXACT.includes(url.pathname)) return true; + return STATIC_PREFIXES.some(p => url.pathname.startsWith(p)); +} + +function fallbackResponse(req, status = 503) { + const accept = req.headers.get('Accept') || ''; + if (accept.includes('application/json')) { + return new Response( + JSON.stringify({ status: 'error', message: 'Network unavailable' }), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (accept.includes('text/event-stream')) { + return new Response('', { + status, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + return new Response('Offline', { + status, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); +} self.addEventListener('install', (e) => { self.skipWaiting(); @@ -45,14 +75,21 @@ self.addEventListener('activate', (e) => { ); }); -self.addEventListener('fetch', (e) => { - const req = e.request; - - // Always bypass service worker for non-GET and streaming routes - if (isNetworkOnly(req)) { - e.respondWith(fetch(req)); - return; - } +self.addEventListener('fetch', (e) => { + const req = e.request; + + // Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched. + if (!isHttpRequest(req)) { + return; + } + + // Always bypass service worker for non-GET and streaming routes + if (isNetworkOnly(req)) { + e.respondWith( + fetch(req).catch(() => fallbackResponse(req, 503)) + ); + return; + } // Cache-first for static assets if (isStaticAsset(req)) { @@ -65,15 +102,15 @@ self.addEventListener('fetch', (e) => { if (res && res.status === 200) cache.put(req, res.clone()); }).catch(() => {}); return cached; - } - return fetch(req).then(res => { - if (res && res.status === 200) cache.put(req, res.clone()); - return res; - }); - }) - ) - ); - return; + } + return fetch(req).then(res => { + if (res && res.status === 200) cache.put(req, res.clone()); + return res; + }).catch(() => fallbackResponse(req, 504)); + }) + ) + ); + return; } // Network-first for HTML pages diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 47a7b19..6a631c8 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -1,5 +1,5 @@ - +
@@ -423,9 +423,16 @@ let alertsEnabled = true; let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on let soundedAircraft = {}; // Track aircraft we've played detection sound for - const MAP_CROSSHAIR_DURATION_MS = 620; + const MAP_CROSSHAIR_DURATION_MS = 1500; + const PANEL_SELECTION_BASE_ZOOM = 10; + const PANEL_SELECTION_MAX_ZOOM = 12; + const PANEL_SELECTION_ZOOM_INCREMENT = 1.4; + const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05; + const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15; + const PANEL_SELECTION_STAGE_GAP_MS = 180; let mapCrosshairResetTimer = null; - let mapCrosshairFallbackTimer = null; + let panelSelectionFallbackTimer = null; + let panelSelectionStageTimer = null; let mapCrosshairRequestId = 0; // Watchlist - persisted to localStorage let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]'); @@ -2792,18 +2799,32 @@ sudo make install `; } - function triggerMapCrosshairAnimation(lat, lon) { + function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) { if (!radarMap) return; const overlay = document.getElementById('mapCrosshairOverlay'); if (!overlay) return; - const point = radarMap.latLngToContainerPoint([lat, lon]); const size = radarMap.getSize(); - const targetX = Math.max(0, Math.min(size.x, point.x)); - const targetY = Math.max(0, Math.min(size.y, point.y)); + let targetX; + let targetY; - overlay.style.setProperty('--target-x', `${targetX}px`); - overlay.style.setProperty('--target-y', `${targetY}px`); + if (lockToMapCenter) { + targetX = size.x / 2; + targetY = size.y / 2; + } else { + const point = radarMap.latLngToContainerPoint([lat, lon]); + targetX = Math.max(0, Math.min(size.x, point.x)); + targetY = Math.max(0, Math.min(size.y, point.y)); + } + + const startX = size.x + 8; + const startY = size.y + 8; + + overlay.style.setProperty('--crosshair-x-start', `${startX}px`); + overlay.style.setProperty('--crosshair-y-start', `${startY}px`); + overlay.style.setProperty('--crosshair-x-end', `${targetX}px`); + overlay.style.setProperty('--crosshair-y-end', `${targetY}px`); + overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`); overlay.classList.remove('active'); void overlay.offsetWidth; overlay.classList.add('active'); @@ -2814,16 +2835,102 @@ sudo make install mapCrosshairResetTimer = setTimeout(() => { overlay.classList.remove('active'); mapCrosshairResetTimer = null; - }, MAP_CROSSHAIR_DURATION_MS + 40); + }, durationMs + 100); + } + + function getPanelSelectionFinalZoom() { + if (!radarMap) return PANEL_SELECTION_BASE_ZOOM; + const currentZoom = radarMap.getZoom(); + const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM; + return Math.min( + PANEL_SELECTION_MAX_ZOOM, + maxZoom, + Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT) + ); + } + + function getPanelSelectionIntermediateZoom(finalZoom) { + if (!radarMap) return finalZoom; + const currentZoom = radarMap.getZoom(); + if (finalZoom - currentZoom < 0.8) { + return finalZoom; + } + const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55); + return Math.min(finalZoom - 0.45, midpointZoom); + } + + function runPanelSelectionAnimation(lat, lon, requestId) { + if (!radarMap) return; + + const finalZoom = getPanelSelectionFinalZoom(); + const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom); + const sequenceDurationMs = Math.round( + ((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) + + PANEL_SELECTION_STAGE_GAP_MS + 260 + ); + const startSecondStage = () => { + if (requestId !== mapCrosshairRequestId) return; + radarMap.flyTo([lat, lon], finalZoom, { + animate: true, + duration: PANEL_SELECTION_STAGE2_DURATION_SEC, + easeLinearity: 0.2 + }); + }; + + triggerMapCrosshairAnimation( + lat, + lon, + Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs), + true + ); + + if (intermediateZoom >= finalZoom - 0.1) { + radarMap.flyTo([lat, lon], finalZoom, { + animate: true, + duration: PANEL_SELECTION_STAGE2_DURATION_SEC, + easeLinearity: 0.2 + }); + return; + } + + let stage1Handled = false; + const finishStage1 = () => { + if (stage1Handled || requestId !== mapCrosshairRequestId) return; + stage1Handled = true; + if (panelSelectionFallbackTimer) { + clearTimeout(panelSelectionFallbackTimer); + panelSelectionFallbackTimer = null; + } + panelSelectionStageTimer = setTimeout(() => { + panelSelectionStageTimer = null; + startSecondStage(); + }, PANEL_SELECTION_STAGE_GAP_MS); + }; + + radarMap.once('moveend', finishStage1); + panelSelectionFallbackTimer = setTimeout( + finishStage1, + Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160 + ); + + radarMap.flyTo([lat, lon], intermediateZoom, { + animate: true, + duration: PANEL_SELECTION_STAGE1_DURATION_SEC, + easeLinearity: 0.2 + }); } function selectAircraft(icao, source = 'map') { const prevSelected = selectedIcao; selectedIcao = icao; mapCrosshairRequestId += 1; - if (mapCrosshairFallbackTimer) { - clearTimeout(mapCrosshairFallbackTimer); - mapCrosshairFallbackTimer = null; + if (panelSelectionFallbackTimer) { + clearTimeout(panelSelectionFallbackTimer); + panelSelectionFallbackTimer = null; + } + if (panelSelectionStageTimer) { + clearTimeout(panelSelectionStageTimer); + panelSelectionStageTimer = null; } // Update marker icons for both previous and new selection @@ -2853,19 +2960,8 @@ sudo make install const targetLon = ac.lon; if (source === 'panel' && radarMap) { - const requestId = mapCrosshairRequestId; - let crosshairTriggered = false; - const runCrosshair = () => { - if (crosshairTriggered || requestId !== mapCrosshairRequestId) return; - crosshairTriggered = true; - if (mapCrosshairFallbackTimer) { - clearTimeout(mapCrosshairFallbackTimer); - mapCrosshairFallbackTimer = null; - } - triggerMapCrosshairAnimation(targetLat, targetLon); - }; - radarMap.once('moveend', runCrosshair); - mapCrosshairFallbackTimer = setTimeout(runCrosshair, 450); + runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId); + return; } radarMap.setView([targetLat, targetLon], 10); diff --git a/templates/adsb_history.html b/templates/adsb_history.html index 3d3b810..b7dee83 100644 --- a/templates/adsb_history.html +++ b/templates/adsb_history.html @@ -1,5 +1,5 @@ - + diff --git a/templates/agents.html b/templates/agents.html index 6cb3398..5faa911 100644 --- a/templates/agents.html +++ b/templates/agents.html @@ -1,5 +1,5 @@ - + diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index eab16c4..aadcd42 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -1,5 +1,5 @@ - + diff --git a/templates/index.html b/templates/index.html index cb729b5..980430d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,5 @@ - + @@ -2193,7 +2193,7 @@