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: Standby
Balloons: 0
@@ -108,6 +109,24 @@