From 90b455aa6c00e5bf9a39db56d12411ca9ce1fe62 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 2 Mar 2026 16:27:09 +0000 Subject: [PATCH] feat: add signal activity waveform component for radiosonde mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable SVG bar waveform (SignalWaveform.Live) that animates in response to incoming SSE data — idle breathing when stopped, active oscillation proportional to telemetry update frequency, smooth decay on signal loss. Integrated into radiosonde Status section with ping() on each balloon message and stop() on tracking stop. Also hardens the fetch error path to show a readable message instead of a JSON parse error when the server returns HTML. Co-Authored-By: Claude Opus 4.6 --- static/css/components/signal-waveform.css | 39 +++++ static/js/components/signal-waveform.js | 184 ++++++++++++++++++++++ templates/index.html | 3 + templates/partials/modes/radiosonde.html | 28 +++- 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 static/css/components/signal-waveform.css create mode 100644 static/js/components/signal-waveform.js diff --git a/static/css/components/signal-waveform.css b/static/css/components/signal-waveform.css new file mode 100644 index 0000000..2b5c6ec --- /dev/null +++ b/static/css/components/signal-waveform.css @@ -0,0 +1,39 @@ +/** + * Signal Waveform Component + * Animated SVG bar waveform for indicating live signal activity + */ + +.signal-waveform { + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.signal-waveform-svg { + display: block; +} + +.signal-waveform-bar { + will-change: height, y; + transition: fill 0.3s ease; +} + +/* Idle breathing animation */ +.signal-waveform.idle .signal-waveform-bar { + animation: signal-waveform-breathe 2.5s ease-in-out infinite; +} + +.signal-waveform.idle .signal-waveform-bar:nth-child(even) { + animation-delay: -1.25s; +} + +@keyframes signal-waveform-breathe { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.7; } +} + +/* Active state - disable CSS breathing, JS drives heights */ +.signal-waveform.active .signal-waveform-bar { + animation: none; + opacity: 1; +} diff --git a/static/js/components/signal-waveform.js b/static/js/components/signal-waveform.js new file mode 100644 index 0000000..d06750e --- /dev/null +++ b/static/js/components/signal-waveform.js @@ -0,0 +1,184 @@ +/** + * Signal Waveform Component + * Animated SVG bar waveform showing live signal activity. + * Flat/breathing when idle, oscillates on incoming data. + */ + +const SignalWaveform = (function() { + 'use strict'; + + const DEFAULT_CONFIG = { + width: 200, + height: 40, + barCount: 24, + color: '#00e5ff', + decayMs: 3000, + idleAmplitude: 0.05, + }; + + class Live { + constructor(container, config = {}) { + this.container = typeof container === 'string' + ? document.querySelector(container) + : container; + this.config = { ...DEFAULT_CONFIG, ...config }; + this.lastPingTime = 0; + this.pingTimestamps = []; + this.animFrameId = null; + this.phase = 0; + this.targetHeights = []; + this.currentHeights = []; + this.stopped = false; + + this._buildSvg(); + this._startLoop(); + } + + /** Signal that a telemetry message arrived */ + ping() { + const now = performance.now(); + this.lastPingTime = now; + this.pingTimestamps.push(now); + this.stopped = false; + // Randomise target heights on each ping + this._randomiseTargets(); + } + + /** Transition to idle */ + stop() { + this.stopped = true; + this.lastPingTime = 0; + this.pingTimestamps = []; + } + + /** Tear down animation loop and DOM */ + destroy() { + if (this.animFrameId) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + if (this.container) { + this.container.innerHTML = ''; + } + } + + // -- Private -- + + _buildSvg() { + if (!this.container) return; + const { width, height, barCount, color } = this.config; + const gap = 1; + const barWidth = (width - gap * (barCount - 1)) / barCount; + const minH = height * this.config.idleAmplitude; + + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('class', 'signal-waveform-svg'); + svg.setAttribute('width', width); + svg.setAttribute('height', height); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + + this.bars = []; + for (let i = 0; i < barCount; i++) { + const rect = document.createElementNS(ns, 'rect'); + const x = i * (barWidth + gap); + rect.setAttribute('x', x.toFixed(1)); + rect.setAttribute('y', (height - minH).toFixed(1)); + rect.setAttribute('width', barWidth.toFixed(1)); + rect.setAttribute('height', minH.toFixed(1)); + rect.setAttribute('rx', '1'); + rect.setAttribute('fill', color); + rect.setAttribute('class', 'signal-waveform-bar'); + svg.appendChild(rect); + this.bars.push(rect); + this.currentHeights.push(minH); + this.targetHeights.push(minH); + } + + const wrapper = document.createElement('div'); + wrapper.className = 'signal-waveform idle'; + wrapper.appendChild(svg); + this.container.innerHTML = ''; + this.container.appendChild(wrapper); + this.wrapperEl = wrapper; + } + + _randomiseTargets() { + const { height } = this.config; + const amplitude = this._getAmplitude(); + for (let i = 0; i < this.config.barCount; i++) { + // Sine envelope with randomisation + const envelope = Math.sin(Math.PI * i / (this.config.barCount - 1)); + const rand = 0.4 + Math.random() * 0.6; + this.targetHeights[i] = Math.max( + height * this.config.idleAmplitude, + height * amplitude * envelope * rand + ); + } + } + + _getAmplitude() { + if (this.stopped) return this.config.idleAmplitude; + const now = performance.now(); + const elapsed = now - this.lastPingTime; + if (this.lastPingTime === 0 || elapsed > this.config.decayMs) { + return this.config.idleAmplitude; + } + + // Prune old timestamps (keep last 5s) + const cutoff = now - 5000; + this.pingTimestamps = this.pingTimestamps.filter(t => t > cutoff); + + // Base amplitude from ping frequency (more pings = higher amplitude) + const freq = this.pingTimestamps.length / 5; // pings per second + const freqAmp = Math.min(1, 0.3 + freq * 0.35); + + // Decay factor + const decay = 1 - (elapsed / this.config.decayMs); + return Math.max(this.config.idleAmplitude, freqAmp * decay); + } + + _startLoop() { + const tick = () => { + this.animFrameId = requestAnimationFrame(tick); + this._update(); + }; + this.animFrameId = requestAnimationFrame(tick); + } + + _update() { + if (!this.bars || !this.bars.length) return; + const { height } = this.config; + const minH = height * this.config.idleAmplitude; + const amplitude = this._getAmplitude(); + const isActive = amplitude > this.config.idleAmplitude * 1.5; + + // Toggle CSS class for breathing vs active + if (this.wrapperEl) { + this.wrapperEl.classList.toggle('idle', !isActive); + this.wrapperEl.classList.toggle('active', isActive); + } + + // When idle, slowly drift targets with phase + if (!isActive) { + this.phase += 0.02; + for (let i = 0; i < this.bars.length; i++) { + this.targetHeights[i] = minH; + } + } + + // Lerp current toward target + const lerp = isActive ? 0.18 : 0.06; + for (let i = 0; i < this.bars.length; i++) { + this.currentHeights[i] += (this.targetHeights[i] - this.currentHeights[i]) * lerp; + const h = Math.max(minH, this.currentHeights[i]); + this.bars[i].setAttribute('height', h.toFixed(1)); + this.bars[i].setAttribute('y', (height - h).toFixed(1)); + } + } + } + + return { Live }; +})(); + +window.SignalWaveform = SignalWaveform; diff --git a/templates/index.html b/templates/index.html index ef6ff78..6edad8d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -66,6 +66,7 @@ + + @@ -4457,6 +4459,7 @@ } else if (mode === 'morse') { MorseMode.init(); } else if (mode === 'radiosonde') { + initRadiosondeWaveform(); initRadiosondeMap(); setTimeout(() => { if (radiosondeMap) radiosondeMap.invalidateSize(); diff --git a/templates/partials/modes/radiosonde.html b/templates/partials/modes/radiosonde.html index fa3c4e3..cad99f0 100644 --- a/templates/partials/modes/radiosonde.html +++ b/templates/partials/modes/radiosonde.html @@ -35,6 +35,7 @@

Status

+

Status: Standby

Balloons: 0

@@ -108,6 +109,24 @@