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> | |
); | |
} |