File
Blob: worker/src/client/exhibit/spectrum/spectrum-canvas.tsx
| 1 | import { useEffect, useRef } from 'react'; |
| 2 | import type { SignalBuffer } from '@/client/radio/session-state.ts'; |
| 3 | |
| 4 | /** Draw the board's bands, never an FFT of audio in the browser. */ |
| 5 | export function SpectrumCanvas({ signal }: { signal: SignalBuffer }) { |
| 6 | const ref = useRef<HTMLCanvasElement>(null); |
| 7 | useEffect(() => { |
| 8 | const canvas = ref.current; |
| 9 | const context = canvas?.getContext('2d'); |
| 10 | if (!canvas || !context) return; |
| 11 | const shown = new Float32Array(32); |
| 12 | const reduced = matchMedia('(prefers-reduced-motion: reduce)'); |
| 13 | let frame = 0, |
| 14 | last = 0; |
| 15 | const draw = (now: number) => { |
| 16 | frame = requestAnimationFrame(draw); |
| 17 | if (document.hidden || now - last < (reduced.matches ? 100 : 32)) return; |
| 18 | const dt = Math.min(0.1, (now - last) / 1000); |
| 19 | last = now; |
| 20 | const width = canvas.clientWidth, |
| 21 | height = canvas.clientHeight, |
| 22 | scale = Math.min(devicePixelRatio || 1, 2); |
| 23 | if ( |
| 24 | canvas.width !== Math.round(width * scale) || |
| 25 | canvas.height !== Math.round(height * scale) |
| 26 | ) { |
| 27 | canvas.width = Math.round(width * scale); |
| 28 | canvas.height = Math.round(height * scale); |
| 29 | } |
| 30 | context.setTransform(scale, 0, 0, scale, 0, 0); |
| 31 | context.clearRect(0, 0, width, height); |
| 32 | context.strokeStyle = '#d9e1da'; |
| 33 | context.lineWidth = 0.7; |
| 34 | for (let row = 0; row < 4; row++) { |
| 35 | const y = Math.round((row * height) / 4) + 0.5; |
| 36 | context.beginPath(); |
| 37 | context.moveTo(0, y); |
| 38 | context.lineTo(width, y); |
| 39 | context.stroke(); |
| 40 | } |
| 41 | const fresh = signal.at > 0 && now - signal.at < 1200; |
| 42 | for (let i = 0; i < 32; i++) { |
| 43 | const target = fresh && signal.frame ? signal.frame.bands[i] / 255 : 0; |
| 44 | shown[i] = reduced.matches |
| 45 | ? target |
| 46 | : shown[i] + (target - shown[i]) * Math.min(1, dt * (target > shown[i] ? 22 : 8)); |
| 47 | const barHeight = Math.max(2, shown[i] * (height - 5)), |
| 48 | barWidth = Math.max(1, width / 32 - 3); |
| 49 | context.fillStyle = fresh ? '#2b7463' : '#b7c9bd'; |
| 50 | context.fillRect((i * width) / 32, height - barHeight, barWidth, barHeight); |
| 51 | } |
| 52 | }; |
| 53 | frame = requestAnimationFrame(draw); |
| 54 | return () => cancelAnimationFrame(frame); |
| 55 | }, [signal]); |
| 56 | return ( |
| 57 | <canvas |
| 58 | ref={ref} |
| 59 | className="block h-20 w-full" |
| 60 | role="img" |
| 61 | aria-label="32 frequency bands calculated on core 1 of the ESP32-S3" |
| 62 | /> |
| 63 | ); |
| 64 | } |