| |
| |
| |
|
|
| |
| let timeDomainChart, magnitudeChart, phaseChart; |
|
|
| |
| const WAVE_COLORS = ['#1976d2', '#d32f2f', '#388e3c', '#f57c00']; |
|
|
| |
| const SAMPLING_RATE = 256; |
| const MAX_SAMPLES_FOR_DFT = 512; |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| function fft(signal) { |
| const N = signal.length; |
| |
| |
| if (N & (N - 1)) { |
| |
| return dft(signal); |
| } |
| |
| |
| const re = new Array(N); |
| const im = new Array(N).fill(0); |
| |
| for (let i = 0; i < N; i++) { |
| let j = 0; |
| let bit = N >> 1; |
| let ii = i; |
| while (bit > 0) { |
| if (ii & 1) j |= bit; |
| ii >>= 1; |
| bit >>= 1; |
| } |
| re[j] = signal[i]; |
| } |
| |
| |
| for (let step = 2; step <= N; step <<= 1) { |
| const halfStep = step >> 1; |
| const angleInc = -2 * Math.PI / step; |
| |
| for (let group = 0; group < N; group += step) { |
| for (let i = 0; i < halfStep; i++) { |
| const angle = angleInc * i; |
| const cos = Math.cos(angle); |
| const sin = Math.sin(angle); |
| |
| const evenRe = re[group + i]; |
| const evenIm = im[group + i]; |
| const oddRe = re[group + i + halfStep] * cos - im[group + i + halfStep] * sin; |
| const oddIm = re[group + i + halfStep] * sin + im[group + i + halfStep] * cos; |
| |
| re[group + i] = evenRe + oddRe; |
| im[group + i] = evenIm + oddIm; |
| re[group + i + halfStep] = evenRe - oddRe; |
| im[group + i + halfStep] = evenIm - oddIm; |
| } |
| } |
| } |
| |
| return { re, im }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function dft(signal) { |
| const N = signal.length; |
| const re = new Array(N).fill(0); |
| const im = new Array(N).fill(0); |
| |
| for (let k = 0; k < N; k++) { |
| let sumRe = 0; |
| let sumIm = 0; |
| for (let n = 0; n < N; n++) { |
| const angle = -2 * Math.PI * k * n / N; |
| sumRe += signal[n] * Math.cos(angle); |
| sumIm += signal[n] * Math.sin(angle); |
| } |
| re[k] = sumRe; |
| im[k] = sumIm; |
| } |
| |
| return { re, im }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function computeMagnitudeSpectrum(dftResult) { |
| const N = dftResult.re.length; |
| const magnitudes = []; |
| |
| |
| for (let k = 0; k <= N / 2; k++) { |
| const mag = Math.sqrt(dftResult.re[k] ** 2 + dftResult.im[k] ** 2); |
| |
| magnitudes.push(2 * mag / N); |
| } |
| |
| |
| magnitudes[0] = magnitudes[0] / 2; |
| if (N % 2 === 0) { |
| magnitudes[magnitudes.length - 1] = magnitudes[magnitudes.length - 1] / 2; |
| } |
| |
| return magnitudes; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function computePhaseSpectrum(dftResult) { |
| const N = dftResult.re.length; |
| const phases = []; |
| |
| for (let k = 0; k <= N / 2; k++) { |
| const phaseRad = Math.atan2(dftResult.im[k], dftResult.re[k]); |
| const phaseDeg = phaseRad * 180 / Math.PI; |
| phases.push(phaseDeg); |
| } |
| |
| return phases; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function generateSineWave(frequency, amplitude, phaseDeg, samplingRate, numSamples) { |
| const signal = []; |
| const phaseRad = phaseDeg * Math.PI / 180; |
| |
| for (let n = 0; n < numSamples; n++) { |
| const t = n / samplingRate; |
| const value = amplitude * Math.sin(2 * Math.PI * frequency * t + phaseRad); |
| signal.push(value); |
| } |
| |
| return signal; |
| } |
|
|
| |
| |
| |
| |
| |
| function generateCompositeSignal() { |
| const numSamples = parseInt(document.getElementById('numSamples').value); |
| const addNoise = document.getElementById('addNoise').checked; |
| const noiseLevel = parseFloat(document.getElementById('noiseLevel').value); |
| |
| |
| const time = []; |
| const signal = new Array(numSamples).fill(0); |
| const individualWaves = []; |
| |
| |
| for (let n = 0; n < numSamples; n++) { |
| time.push(n / SAMPLING_RATE); |
| } |
| |
| |
| for (let i = 1; i <= 4; i++) { |
| const enabled = document.getElementById(`enableWave${i}`).checked; |
| if (!enabled) continue; |
| |
| const frequency = parseInt(document.getElementById(`freq${i}`).value); |
| const amplitude = parseFloat(document.getElementById(`amp${i}`).value); |
| const phase = parseInt(document.getElementById(`phase${i}`).value); |
| |
| |
| const wave = generateSineWave(frequency, amplitude, phase, SAMPLING_RATE, numSamples); |
| individualWaves.push({ |
| frequency, |
| amplitude, |
| phase, |
| color: WAVE_COLORS[i - 1], |
| data: wave |
| }); |
| |
| |
| for (let n = 0; n < numSamples; n++) { |
| signal[n] += wave[n]; |
| } |
| } |
| |
| |
| if (addNoise) { |
| for (let n = 0; n < numSamples; n++) { |
| signal[n] += randomGaussian(0, noiseLevel); |
| } |
| } |
| |
| return { time, signal, individualWaves, numSamples }; |
| } |
|
|
| |
|
|
| function initCharts() { |
| |
| const timeCtx = document.getElementById('timeDomainChart').getContext('2d'); |
| timeDomainChart = new Chart(timeCtx, { |
| type: 'line', |
| data: { |
| datasets: [ |
| { |
| label: 'Composite Signal', |
| data: [], |
| borderColor: '#1976d2', |
| backgroundColor: 'rgba(25, 118, 210, 0.1)', |
| borderWidth: 2, |
| pointRadius: 0, |
| fill: true, |
| tension: 0.1 |
| }, |
| { |
| label: 'Wave 1', |
| data: [], |
| borderColor: WAVE_COLORS[0], |
| borderWidth: 1, |
| pointRadius: 0, |
| borderDash: [5, 5], |
| fill: false, |
| tension: 0.1, |
| hidden: false |
| }, |
| { |
| label: 'Wave 2', |
| data: [], |
| borderColor: WAVE_COLORS[1], |
| borderWidth: 1, |
| pointRadius: 0, |
| borderDash: [5, 5], |
| fill: false, |
| tension: 0.1, |
| hidden: false |
| }, |
| { |
| label: 'Wave 3', |
| data: [], |
| borderColor: WAVE_COLORS[2], |
| borderWidth: 1, |
| pointRadius: 0, |
| borderDash: [5, 5], |
| fill: false, |
| tension: 0.1, |
| hidden: true |
| }, |
| { |
| label: 'Wave 4', |
| data: [], |
| borderColor: WAVE_COLORS[3], |
| borderWidth: 1, |
| pointRadius: 0, |
| borderDash: [5, 5], |
| fill: false, |
| tension: 0.1, |
| hidden: true |
| } |
| ] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| animation: { duration: 0 }, |
| interaction: { |
| mode: 'index', |
| intersect: false |
| }, |
| scales: { |
| x: { |
| type: 'linear', |
| title: { display: true, text: 'Time (s)' } |
| }, |
| y: { |
| title: { display: true, text: 'Amplitude' }, |
| min: -25, |
| max: 25 |
| } |
| }, |
| plugins: { |
| legend: { |
| display: true, |
| labels: { |
| filter: function(item, data) { |
| |
| const dataset = data.datasets[item.datasetIndex]; |
| return !dataset.hidden; |
| } |
| } |
| }, |
| tooltip: { |
| callbacks: { |
| title: function(context) { |
| return `t = ${context[0].parsed.x.toFixed(4)} s`; |
| } |
| } |
| } |
| } |
| } |
| }); |
|
|
| |
| const magCtx = document.getElementById('magnitudeChart').getContext('2d'); |
| magnitudeChart = new Chart(magCtx, { |
| type: 'bar', |
| data: { |
| datasets: [{ |
| label: 'Magnitude', |
| data: [], |
| backgroundColor: 'rgba(25, 118, 210, 0.7)', |
| borderColor: '#1976d2', |
| borderWidth: 1 |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| animation: { duration: 0 }, |
| scales: { |
| x: { |
| type: 'linear', |
| title: { display: true, text: 'Frequency (Hz)' }, |
| min: 0 |
| }, |
| y: { |
| title: { display: true, text: '|X(f)|' }, |
| beginAtZero: true |
| } |
| }, |
| plugins: { |
| legend: { display: false }, |
| tooltip: { |
| callbacks: { |
| title: function(context) { |
| return `f = ${context[0].parsed.x.toFixed(1)} Hz`; |
| }, |
| label: function(context) { |
| return `Magnitude: ${context.parsed.y.toFixed(3)}`; |
| } |
| } |
| } |
| } |
| } |
| }); |
|
|
| |
| const phaseCtx = document.getElementById('phaseChart').getContext('2d'); |
| phaseChart = new Chart(phaseCtx, { |
| type: 'scatter', |
| data: { |
| datasets: [{ |
| label: 'Phase', |
| data: [], |
| backgroundColor: 'rgba(46, 125, 50, 0.7)', |
| borderColor: '#2e7d32', |
| borderWidth: 1, |
| pointRadius: 4 |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| animation: { duration: 0 }, |
| scales: { |
| x: { |
| type: 'linear', |
| title: { display: true, text: 'Frequency (Hz)' }, |
| min: 0 |
| }, |
| y: { |
| title: { display: true, text: 'Phase (degrees)' }, |
| min: -180, |
| max: 180, |
| ticks: { |
| stepSize: 45 |
| } |
| } |
| }, |
| plugins: { |
| legend: { display: false }, |
| tooltip: { |
| callbacks: { |
| title: function(context) { |
| return `f = ${context[0].parsed.x.toFixed(1)} Hz`; |
| }, |
| label: function(context) { |
| return `Phase: ${context.parsed.y.toFixed(1)}°`; |
| } |
| } |
| } |
| } |
| } |
| }); |
| } |
|
|
| |
|
|
| function updateVisualizations() { |
| const { time, signal, individualWaves, numSamples } = generateCompositeSignal(); |
| |
| |
| timeDomainChart.data.datasets[0].data = signal.map((y, i) => ({ x: time[i], y })); |
| |
| |
| for (let i = 0; i < 4; i++) { |
| const waveData = individualWaves.find(w => w.color === WAVE_COLORS[i]); |
| if (waveData) { |
| timeDomainChart.data.datasets[i + 1].data = waveData.data.map((y, j) => ({ x: time[j], y })); |
| timeDomainChart.data.datasets[i + 1].hidden = false; |
| } else { |
| timeDomainChart.data.datasets[i + 1].data = []; |
| timeDomainChart.data.datasets[i + 1].hidden = true; |
| } |
| } |
| |
| |
| const dftResult = fft(signal); |
| const magnitudes = computeMagnitudeSpectrum(dftResult); |
| const phases = computePhaseSpectrum(dftResult); |
| |
| |
| const freqResolution = SAMPLING_RATE / numSamples; |
| const frequencies = magnitudes.map((_, k) => k * freqResolution); |
| const nyquistFreq = SAMPLING_RATE / 2; |
| |
| |
| |
| const maxFreq = Math.min(50, nyquistFreq); |
| const filteredFreqIndices = frequencies |
| .map((f, i) => ({ f, i })) |
| .filter(item => item.f <= maxFreq); |
| |
| magnitudeChart.data.datasets[0].data = filteredFreqIndices.map(item => ({ |
| x: item.f, |
| y: magnitudes[item.i] |
| })); |
| magnitudeChart.options.scales.x.max = maxFreq; |
| |
| |
| |
| const maxMagnitude = Math.max(...magnitudes); |
| const threshold = Math.max(maxMagnitude * 0.01, 0.001); |
| |
| const significantPhases = filteredFreqIndices |
| .filter(item => magnitudes[item.i] > threshold) |
| .map(item => ({ |
| x: item.f, |
| y: phases[item.i] |
| })); |
| |
| phaseChart.data.datasets[0].data = significantPhases; |
| phaseChart.options.scales.x.max = maxFreq; |
| |
| |
| timeDomainChart.update('none'); |
| magnitudeChart.update('none'); |
| phaseChart.update('none'); |
| |
| |
| updateSignalInfo(numSamples, freqResolution, signal, magnitudes); |
| } |
|
|
| function updateSignalInfo(numSamples, freqResolution, signal, magnitudes) { |
| const nyquistFreq = SAMPLING_RATE / 2; |
| const duration = numSamples / SAMPLING_RATE; |
| |
| |
| const totalPower = magnitudes.reduce((sum, mag, i) => { |
| |
| const factor = (i === 0 || (i === magnitudes.length - 1 && numSamples % 2 === 0)) ? 1 : 0.5; |
| return sum + factor * mag * mag; |
| }, 0); |
| |
| |
| const rmsAmplitude = Math.sqrt(signal.reduce((sum, val) => sum + val * val, 0) / signal.length); |
| |
| document.getElementById('samplingRate').textContent = `${SAMPLING_RATE} Hz`; |
| document.getElementById('nyquistFreq').textContent = `${nyquistFreq} Hz`; |
| document.getElementById('freqResolution').textContent = `${freqResolution.toFixed(3)} Hz`; |
| document.getElementById('signalDuration').textContent = `${duration.toFixed(3)} s`; |
| document.getElementById('totalPower').textContent = totalPower.toFixed(3); |
| document.getElementById('rmsAmplitude').textContent = rmsAmplitude.toFixed(3); |
| } |
|
|
| |
|
|
| function setupEventListeners() { |
| |
| for (let i = 1; i <= 4; i++) { |
| document.getElementById(`enableWave${i}`).addEventListener('change', function() { |
| const waveSection = this.closest('.wave-section'); |
| if (this.checked) { |
| waveSection.classList.remove('disabled'); |
| } else { |
| waveSection.classList.add('disabled'); |
| } |
| updateVisualizations(); |
| }); |
| } |
| |
| |
| for (let i = 1; i <= 4; i++) { |
| document.getElementById(`freq${i}`).addEventListener('input', function() { |
| document.getElementById(`freq${i}Value`).textContent = `${this.value} Hz`; |
| updateVisualizations(); |
| }); |
| |
| document.getElementById(`amp${i}`).addEventListener('input', function() { |
| document.getElementById(`amp${i}Value`).textContent = parseFloat(this.value).toFixed(1); |
| updateVisualizations(); |
| }); |
| |
| document.getElementById(`phase${i}`).addEventListener('input', function() { |
| document.getElementById(`phase${i}Value`).textContent = `${this.value}°`; |
| updateVisualizations(); |
| }); |
| } |
| |
| |
| document.getElementById('numSamples').addEventListener('input', function() { |
| document.getElementById('numSamplesValue').textContent = this.value; |
| updateVisualizations(); |
| }); |
| |
| |
| document.getElementById('addNoise').addEventListener('change', function() { |
| const noiseLevelGroup = document.getElementById('noiseLevelGroup'); |
| const noiseLevelSlider = document.getElementById('noiseLevel'); |
| |
| if (this.checked) { |
| noiseLevelGroup.style.opacity = '1'; |
| noiseLevelSlider.disabled = false; |
| } else { |
| noiseLevelGroup.style.opacity = '0.4'; |
| noiseLevelSlider.disabled = true; |
| } |
| updateVisualizations(); |
| }); |
| |
| |
| document.getElementById('noiseLevel').addEventListener('input', function() { |
| document.getElementById('noiseLevelValue').textContent = parseFloat(this.value).toFixed(1); |
| updateVisualizations(); |
| }); |
| |
| } |
|
|
| |
|
|
| function main() { |
| initCharts(); |
| setupEventListeners(); |
| |
| |
| if (window.innerWidth > 1200) { |
| makeDraggable(document.getElementById('floatingControls'), document.getElementById('controlsTitle')); |
| } |
| |
| |
| updateVisualizations(); |
| } |
|
|
| window.addEventListener('load', main); |
|
|