This visualization was completed for my thesis on measuring the dissapation of transverse sound waves in aqueous media under adiabatic conditions. Jk, its just a cool UV playground
Try it out here!
| import React, { useState, useCallback, useEffect, useRef } from 'react'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Switch } from '@/components/ui/switch'; | |
| import { Slider } from '@/components/ui/slider'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Label } from '@/components/ui/label'; | |
| import { LineChart, Line, XAxis, YAxis, CartesianGrid } from 'recharts'; | |
| const TRANSITION_DURATION = 3000; | |
| const FPS_UPDATE_INTERVAL = 500; | |
| const LED_COUNT = 20; | |
| const DEFAULT_TIME_SPEED = 2; | |
| // Updated presets to include RGB waves | |
| const PRESETS = { | |
| calm: { | |
| name: "Calm", | |
| channels: ['r', 'g', 'b'].map(() => | |
| Array(11).fill(null).map((_, i) => ({ | |
| amplitude: i === 0 ? 0.8 : i === 1 ? 0.3 : 0, | |
| phase: 0 | |
| })) | |
| ), | |
| description: "Gentle, in-phase waves" | |
| }, | |
| excited: { | |
| name: "Excited", | |
| channels: ['r', 'g', 'b'].map((_, channelIndex) => | |
| Array(11).fill(null).map((_, i) => ({ | |
| amplitude: Math.exp(-i/3) * 0.8, | |
| phase: i * Math.PI / 4 + (channelIndex * 2 * Math.PI / 3) // 120° phase shift between channels | |
| })) | |
| ), | |
| description: "Phase-shifted energy" | |
| }, | |
| angry: { | |
| name: "Angry", | |
| channels: ['r', 'g', 'b'].map((_, channelIndex) => | |
| Array(11).fill(null).map((_, i) => ({ | |
| amplitude: i < 7 ? 0.7 - i * 0.05 : 0, | |
| phase: (i % 2) * Math.PI + (channelIndex * Math.PI / 2) | |
| })) | |
| ), | |
| description: "Dissonant phases" | |
| }, | |
| peaceful: { | |
| name: "Peaceful", | |
| channels: ['r', 'g', 'b'].map((_, channelIndex) => | |
| Array(11).fill(null).map((_, i) => ({ | |
| amplitude: 1 / (i + 1), | |
| phase: i * Math.PI / 12 + (channelIndex * Math.PI / 4) | |
| })) | |
| ), | |
| description: "Harmonic phase alignment" | |
| } | |
| }; | |
| export default function WaveSimulation() { | |
| const [channels, setChannels] = useState(PRESETS.calm.channels); | |
| const [targetChannels, setTargetChannels] = useState(PRESETS.calm.channels); | |
| const [isRunning, setIsRunning] = useState(true); | |
| const [showIndividual, setShowIndividual] = useState(false); | |
| const [currentPreset, setCurrentPreset] = useState('calm'); | |
| const [isTransitioning, setIsTransitioning] = useState(false); | |
| const [showLEDs, setShowLEDs] = useState(false); | |
| const [timeSpeed, setTimeSpeed] = useState(DEFAULT_TIME_SPEED); | |
| const [fps, setFps] = useState(0); | |
| const timeRef = useRef(0); | |
| const lastFrameTimeRef = useRef(0); | |
| const frameCountRef = useRef(0); | |
| const lastFpsUpdateRef = useRef(0); | |
| const animationFrameRef = useRef(null); | |
| const chartDataRef = useRef([]); | |
| // Calculate wave value for each channel | |
| const calculateWaveValue = useCallback((x, time, channelIndex) => { | |
| let sum = 0; | |
| for (let n = 0; n <= 10; n++) { | |
| const { amplitude, phase } = channels[channelIndex][n]; | |
| sum += amplitude * Math.sin(n * x - n * time + phase); | |
| } | |
| return sum; | |
| }, [channels]); | |
| const updateFPS = useCallback((currentTime) => { | |
| frameCountRef.current++; | |
| if (currentTime - lastFpsUpdateRef.current >= FPS_UPDATE_INTERVAL) { | |
| setFps(Math.round((frameCountRef.current * 1000) / (currentTime - lastFpsUpdateRef.current))); | |
| frameCountRef.current = 0; | |
| lastFpsUpdateRef.current = currentTime; | |
| } | |
| }, []); | |
| // Main animation loop | |
| const animate = useCallback((currentTime) => { | |
| updateFPS(currentTime); | |
| if (!lastFrameTimeRef.current) lastFrameTimeRef.current = currentTime; | |
| const deltaTime = (currentTime - lastFrameTimeRef.current) / 1000; | |
| lastFrameTimeRef.current = currentTime; | |
| // Update time only if animation is running | |
| if (isRunning) { | |
| timeRef.current = (timeRef.current + deltaTime * timeSpeed) % (2 * Math.PI); | |
| } | |
| if (showLEDs) { | |
| const data = []; | |
| for (let i = 0; i < LED_COUNT; i++) { | |
| const x = (i / (LED_COUNT - 1)) * 2 * Math.PI; | |
| const values = channels.map((_, channelIndex) => | |
| calculateWaveValue(x, timeRef.current, channelIndex) | |
| ); | |
| data.push({ | |
| x, | |
| values, | |
| r: Math.max(0, Math.min(1, (values[0] + 2) / 4)), // Clamp between 0 and 1 | |
| g: Math.max(0, Math.min(1, (values[1] + 2) / 4)), | |
| b: Math.max(0, Math.min(1, (values[2] + 2) / 4)) | |
| }); | |
| } | |
| chartDataRef.current = data; | |
| } else { | |
| const points = 100; | |
| const data = []; | |
| for (let x = 0; x < points; x++) { | |
| const xPos = (x / points) * 2 * Math.PI; | |
| const values = channels.map((_, channelIndex) => | |
| calculateWaveValue(xPos, timeRef.current, channelIndex) | |
| ); | |
| data.push({ | |
| x: xPos, | |
| r: values[0], | |
| g: values[1], | |
| b: values[2] | |
| }); | |
| } | |
| chartDataRef.current = data; | |
| } | |
| animationFrameRef.current = requestAnimationFrame(animate); | |
| }, [timeSpeed, channels, showLEDs, calculateWaveValue, updateFPS, isRunning]); | |
| // Mode transition animation | |
| useEffect(() => { | |
| if (!isTransitioning) return; | |
| const startTime = Date.now(); | |
| const startChannels = channels.map(channel => [...channel]); | |
| const animateTransition = () => { | |
| const elapsed = Date.now() - startTime; | |
| const progress = Math.min(elapsed / TRANSITION_DURATION, 1); | |
| const newChannels = startChannels.map((channel, channelIndex) => | |
| channel.map((start, modeIndex) => { | |
| const target = targetChannels[channelIndex][modeIndex]; | |
| return { | |
| amplitude: start.amplitude + (target.amplitude - start.amplitude) * progress, | |
| phase: start.phase + (target.phase - start.phase) * progress | |
| }; | |
| }) | |
| ); | |
| setChannels(newChannels); | |
| if (progress < 1) { | |
| requestAnimationFrame(animateTransition); | |
| } else { | |
| setIsTransitioning(false); | |
| } | |
| }; | |
| requestAnimationFrame(animateTransition); | |
| }, [isTransitioning, targetChannels]); | |
| // Animation frame management - now runs continuously | |
| useEffect(() => { | |
| animationFrameRef.current = requestAnimationFrame(animate); | |
| return () => { | |
| if (animationFrameRef.current) { | |
| cancelAnimationFrame(animationFrameRef.current); | |
| } | |
| }; | |
| }, [animate]); | |
| const applyPreset = useCallback((presetName) => { | |
| setCurrentPreset(presetName); | |
| setTargetChannels(PRESETS[presetName].channels); | |
| setIsTransitioning(true); | |
| }, []); | |
| const LEDDisplay = useCallback(({ data }) => ( | |
| <div className="flex justify-between items-center h-64 w-full bg-gray-900 p-4 rounded-lg"> | |
| {data.map((point, i) => ( | |
| <div | |
| key={i} | |
| className="w-4 h-4 rounded-full transition-all duration-100" | |
| style={{ | |
| backgroundColor: `rgb(${point.r * 255}, ${point.g * 255}, ${point.b * 255})`, | |
| boxShadow: `0 0 10px 5px rgba(${point.r * 255}, ${point.g * 255}, ${point.b * 255}, 0.5)` | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| ), []); | |
| return ( | |
| <Card className="w-full max-w-4xl"> | |
| <CardHeader> | |
| <CardTitle className="flex justify-between items-center"> | |
| <span>RGB Wave Synthesis</span> | |
| <span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded"> | |
| {fps} FPS | |
| </span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-6"> | |
| <div className="flex flex-wrap gap-4"> | |
| {Object.entries(PRESETS).map(([key, preset]) => ( | |
| <div key={key} className="flex flex-col items-center"> | |
| <Button | |
| onClick={() => applyPreset(key)} | |
| variant={currentPreset === key ? "default" : "outline"} | |
| className="w-24" | |
| > | |
| {preset.name} | |
| </Button> | |
| <span className="text-xs text-gray-500 mt-1">{preset.description}</span> | |
| </div> | |
| ))} | |
| </div> | |
| {showLEDs ? ( | |
| <LEDDisplay data={chartDataRef.current} /> | |
| ) : ( | |
| <LineChart width={700} height={300} data={chartDataRef.current}> | |
| <CartesianGrid strokeDasharray="3 3" /> | |
| <XAxis dataKey="x" /> | |
| <YAxis /> | |
| <Line | |
| type="monotone" | |
| dataKey="r" | |
| stroke="#ef4444" | |
| dot={false} | |
| strokeWidth={2} | |
| isAnimationActive={false} | |
| /> | |
| <Line | |
| type="monotone" | |
| dataKey="g" | |
| stroke="#22c55e" | |
| dot={false} | |
| strokeWidth={2} | |
| isAnimationActive={false} | |
| /> | |
| <Line | |
| type="monotone" | |
| dataKey="b" | |
| stroke="#3b82f6" | |
| dot={false} | |
| strokeWidth={2} | |
| isAnimationActive={false} | |
| /> | |
| </LineChart> | |
| )} | |
| <div className="space-y-4"> | |
| <div className="flex items-center space-x-4"> | |
| <Switch | |
| checked={isRunning} | |
| onCheckedChange={setIsRunning} | |
| /> | |
| <Label>Animate</Label> | |
| <Switch | |
| checked={showLEDs} | |
| onCheckedChange={setShowLEDs} | |
| /> | |
| <Label>LED VU Meter</Label> | |
| </div> | |
| <div className="flex items-center space-x-4"> | |
| <Label className="w-24">Speed:</Label> | |
| <Slider | |
| value={[timeSpeed]} | |
| min={0.1} | |
| max={5} | |
| step={0.1} | |
| className="w-48" | |
| onValueChange={([value]) => setTimeSpeed(value)} | |
| /> | |
| <span className="w-12 text-sm">{timeSpeed.toFixed(1)}x</span> | |
| </div> | |
| {['Red', 'Green', 'Blue'].map((channelName, channelIndex) => ( | |
| <div key={channelName} className="space-y-2"> | |
| <h3 className="font-bold">{channelName} Channel</h3> | |
| <div className="grid grid-cols-2 gap-4"> | |
| {channels[channelIndex].map((mode, i) => ( | |
| <div key={i} className="space-y-2"> | |
| <div className="flex items-center space-x-4"> | |
| <Label className="w-24">Mode {i} Amp:</Label> | |
| <Slider | |
| value={[mode.amplitude]} | |
| min={0} | |
| max={1} | |
| step={0.01} | |
| className="w-48" | |
| onValueChange={([value]) => { | |
| const newChannels = channels.map((ch, idx) => | |
| idx === channelIndex | |
| ? ch.map((m, mi) => | |
| mi === i ? {...m, amplitude: value} : m | |
| ) | |
| : ch | |
| ); | |
| setChannels(newChannels); | |
| setTargetChannels(newChannels); | |
| }} | |
| /> | |
| </div> | |
| <div className="flex items-center space-x-4"> | |
| <Label className="w-24">Mode {i} Phase:</Label> | |
| <Slider | |
| value={[mode.phase]} | |
| min={0} | |
| max={2 * Math.PI} | |
| step={0.1} | |
| className="w-48" | |
| onValueChange={([value]) => { | |
| const newChannels = channels.map((ch, idx) => | |
| idx === channelIndex | |
| ? ch.map((m, mi) => | |
| mi === i ? {...m, phase: value} : m | |
| ) | |
| : ch | |
| ); | |
| setChannels(newChannels); | |
| setTargetChannels(newChannels); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } |