feat(labs): wave 2 — depth features across 6 sims
Электрические цепи (circuit): - Индуктивность L как новый компонент (1–1000 мГн, шорт в DC, jωL в AC) - RLC preset для демонстрации резонанса - Осциллограф: U(t)/I(t) для выбранного компонента, 100 sample, dual-axis - Heatmap мощности: радиальный градиент halo от blue→red пропорционально P=UI Стереометрия 3D (stereo): - Сечение через 3 произвольные точки: pick на гранях/рёбрах/вершинах - Плоскость + полигон пересечения с авто-определением типа (3–6-угольник) и площадью - Step-by-step режим: визуализация P1→линия→P2→линия→P3→плоскость→сечение - Поддержка всех solids (включая cylinder/cone через sampling fallback) Планиметрия (geometry): - Задачник framework: CHALLENGES[] с setup/check функциями - 5 стартовых задач: серединный перпендикуляр, биссектриса, описанная окружность, ГМТ, касательная - Авто-checker: толерантности ±0.5° для углов, ±1–5% для расстояний - UI: collapsible панель с статус-иконками, конфетти + «Молодец!» на success Электромагнитные поля (emfield): - Preset «Тороид»: 16+16 проводов в концентрических кольцах - Поверхность Гаусса: draggable круг, считает Φ = q_enc/ε₀, подсвечивает охваченные заряды - Motional EMF: draggable rod, arrow-keys управление, считает ε = ∫(v×B)·dl Химическая песочница (chemsandbox): - Live-overlay с уравнением реакции: молекулярное / полное ионное / сокращённое ионное - Coverage: 49/49 молекулярных, 34/49 ионных, 36/49 сокращённых - Auto-hide через 5 сек, fade-in animation, цветовая кодировка типов Волны и звук (waves): - Doppler: source+observer drag, expanding wavefronts, f_obs формула, Mach cone при v>c - Beats: f1+f2, sum waveform с envelope, индикация f_beat=|f1-f2| - Spectrum (DFT): N=256 samples pure JS, bar-chart с пиками и labels, «Добавить гармонику» Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+476
-17
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
/* ═══════════════════════════════════════════
|
||||
WavesSim v2 — Волны и звук
|
||||
WavesSim v3 — Волны и звук
|
||||
Modes: transverse | longitudinal | superposition | standing
|
||||
doppler | beats | spectrum
|
||||
─────────────────────────────────────────── */
|
||||
class WavesSim {
|
||||
static BG = '#0D0D1A';
|
||||
@@ -29,6 +30,25 @@ class WavesSim {
|
||||
this._n = 1;
|
||||
this._speed = 2.0;
|
||||
|
||||
/* doppler state */
|
||||
this._dopSrcX = 0; this._dopSrcY = 0;
|
||||
this._dopObsX = 0; this._dopObsY = 0;
|
||||
this._dopRings = []; /* [{x,y,r,age}] */
|
||||
this._dopDrag = null; /* 'src'|'obs'|null */
|
||||
this._dopVs = 0.35; /* source speed, px/s as fraction of c_px */
|
||||
this._dopDir = 1; /* +1 right, -1 left */
|
||||
this._dopSrcVelX = 0;
|
||||
this._dopSrcVelY = 0;
|
||||
this._dopLastEmit = 0;
|
||||
|
||||
/* beats state */
|
||||
this._beatsF1 = 440;
|
||||
this._beatsF2 = 444;
|
||||
|
||||
/* spectrum state */
|
||||
this._specComponents = []; /* [{f, A}] */
|
||||
this._specNewF = 5; /* Hz of component to add (slider) */
|
||||
|
||||
this._resizeObs = null;
|
||||
this.onUpdate = null;
|
||||
}
|
||||
@@ -55,6 +75,9 @@ class WavesSim {
|
||||
this._mode = mode;
|
||||
this._t = 0;
|
||||
this._last = null;
|
||||
if (mode === 'doppler') this._dopInit();
|
||||
if (mode === 'spectrum' && !this._specComponents.length)
|
||||
this._specComponents = [{ f: 5, A: 60 }, { f: 10, A: 30 }];
|
||||
this.draw();
|
||||
this._emit();
|
||||
}
|
||||
@@ -63,15 +86,20 @@ class WavesSim {
|
||||
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
|
||||
n: this._n, speed: this._speed, mode: this._mode };
|
||||
}
|
||||
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
|
||||
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
|
||||
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
|
||||
if (phi1 !== undefined) this._phi1 = +phi1;
|
||||
if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2));
|
||||
if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2));
|
||||
if (phi2 !== undefined) this._phi2 = +phi2;
|
||||
if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n)));
|
||||
if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed));
|
||||
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed,
|
||||
dopVs, beatsF1, beatsF2, specNewF } = {}) {
|
||||
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
|
||||
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
|
||||
if (phi1 !== undefined) this._phi1 = +phi1;
|
||||
if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2));
|
||||
if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2));
|
||||
if (phi2 !== undefined) this._phi2 = +phi2;
|
||||
if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n)));
|
||||
if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed));
|
||||
if (dopVs !== undefined) this._dopVs = Math.max(0, Math.min(1.8, +dopVs));
|
||||
if (beatsF1 !== undefined) this._beatsF1 = Math.max(1, Math.min(1000, +beatsF1));
|
||||
if (beatsF2 !== undefined) this._beatsF2 = Math.max(1, Math.min(1000, +beatsF2));
|
||||
if (specNewF !== undefined) this._specNewF = Math.max(1, Math.min(50, +specNewF));
|
||||
this.draw();
|
||||
this._emit();
|
||||
}
|
||||
@@ -103,8 +131,11 @@ class WavesSim {
|
||||
|
||||
_tick(ts) {
|
||||
if (!this._paused) {
|
||||
if (this._last !== null)
|
||||
this._t += Math.min((ts - this._last) / 1000, 0.05) * this._speed;
|
||||
if (this._last !== null) {
|
||||
const dt = Math.min((ts - this._last) / 1000, 0.05) * this._speed;
|
||||
this._t += dt;
|
||||
if (this._mode === 'doppler') this._dopStep(dt);
|
||||
}
|
||||
this._last = ts;
|
||||
this._raf = requestAnimationFrame(t => this._tick(t));
|
||||
} else {
|
||||
@@ -126,7 +157,10 @@ class WavesSim {
|
||||
if (this._mode === 'transverse') this._transvDraw(ctx, W, H);
|
||||
else if (this._mode === 'longitudinal') this._longDraw(ctx, W, H);
|
||||
else if (this._mode === 'superposition') this._superDraw(ctx, W, H);
|
||||
else this._standDraw(ctx, W, H);
|
||||
else if (this._mode === 'standing') this._standDraw(ctx, W, H);
|
||||
else if (this._mode === 'doppler') this._dopplerDraw(ctx, W, H);
|
||||
else if (this._mode === 'beats') this._beatsDraw(ctx, W, H);
|
||||
else if (this._mode === 'spectrum') this._spectrumDraw(ctx, W, H);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
@@ -410,6 +444,363 @@ class WavesSim {
|
||||
ctx.fillText('n = ' + n + ' \u03bb = 2L/' + n, PL, PT - 14);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
ЭФФЕКТ ДОПЛЕРА
|
||||
══════════════════════════════════════ */
|
||||
|
||||
_dopInit() {
|
||||
const W = this._W || 600, H = this._H || 400;
|
||||
this._dopSrcX = W * 0.3; this._dopSrcY = H * 0.5;
|
||||
this._dopObsX = W * 0.75; this._dopObsY = H * 0.5;
|
||||
this._dopRings = [];
|
||||
this._dopLastEmit = 0;
|
||||
this._dopDir = 1;
|
||||
}
|
||||
|
||||
_dopStep(dt) {
|
||||
const W = this._W || 600, H = this._H || 400;
|
||||
/* speed in px/s: c_px ~= W*0.55 so Mach 1 is full screen width */
|
||||
const c_px = W * 0.55;
|
||||
const vsPx = this._dopVs * c_px;
|
||||
|
||||
/* move source horizontally, bounce at margins */
|
||||
if (!this._dopDrag || this._dopDrag !== 'src') {
|
||||
this._dopSrcX += this._dopDir * vsPx * dt;
|
||||
if (this._dopSrcX > W - 30) { this._dopSrcX = W - 30; this._dopDir = -1; }
|
||||
if (this._dopSrcX < 30) { this._dopSrcX = 30; this._dopDir = 1; }
|
||||
}
|
||||
this._dopSrcVelX = this._dopDir * vsPx;
|
||||
this._dopSrcVelY = 0;
|
||||
|
||||
/* emit rings at source frequency f0 = _f1 */
|
||||
const f0 = Math.max(0.5, this._f1);
|
||||
this._dopLastEmit += dt;
|
||||
const emitInterval = 1 / f0;
|
||||
while (this._dopLastEmit >= emitInterval) {
|
||||
this._dopLastEmit -= emitInterval;
|
||||
this._dopRings.push({ x: this._dopSrcX, y: this._dopSrcY, r: 0, age: 0 });
|
||||
}
|
||||
|
||||
/* expand rings at c_px */
|
||||
const maxR = Math.sqrt(W * W + H * H);
|
||||
this._dopRings = this._dopRings.filter(ring => {
|
||||
ring.r += c_px * dt;
|
||||
ring.age += dt;
|
||||
return ring.r < maxR;
|
||||
});
|
||||
}
|
||||
|
||||
_dopplerDraw(ctx, W, H) {
|
||||
if (!this._dopSrcX) this._dopInit();
|
||||
|
||||
const c_px = W * 0.55;
|
||||
const vs = this._dopVs * c_px; /* px/s */
|
||||
const f0 = Math.max(0.5, this._f1);
|
||||
|
||||
/* observed frequency (source moving toward/away observer) */
|
||||
const dx = this._dopObsX - this._dopSrcX;
|
||||
const dy = this._dopObsY - this._dopSrcY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const cosAngle = dx / dist;
|
||||
/* projection of source velocity onto source→observer */
|
||||
const vsProj = this._dopDir * vs * cosAngle; /* +: toward obs */
|
||||
const fObs = f0 * c_px / Math.max(1, c_px - vsProj);
|
||||
const mach = vs / c_px;
|
||||
|
||||
/* draw rings */
|
||||
const ringAlpha = 0.55;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = WavesSim.C;
|
||||
ctx.lineWidth = 1.5;
|
||||
this._dopRings.forEach(ring => {
|
||||
const a = Math.max(0, ringAlpha * (1 - ring.age * f0 * 0.5));
|
||||
if (a < 0.02) return;
|
||||
ctx.globalAlpha = a;
|
||||
ctx.beginPath();
|
||||
ctx.arc(ring.x, ring.y, ring.r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
/* Mach cone if vs >= c_px */
|
||||
if (mach >= 1.0) {
|
||||
const sinTheta = Math.min(1, c_px / vs);
|
||||
const theta = Math.asin(sinTheta);
|
||||
const coneLen = W * 0.9;
|
||||
const sx = this._dopSrcX, sy = this._dopSrcY;
|
||||
const dir = this._dopDir;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = WavesSim.P;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
|
||||
sy - coneLen * Math.sin(theta));
|
||||
ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
|
||||
sy + coneLen * Math.sin(theta));
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.save();
|
||||
ctx.strokeStyle = WavesSim.P; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.7;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
|
||||
sy - coneLen * Math.sin(theta));
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
|
||||
sy + coneLen * Math.sin(theta));
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* source dot */
|
||||
ctx.save();
|
||||
ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18;
|
||||
ctx.fillStyle = WavesSim.G;
|
||||
ctx.beginPath(); ctx.arc(this._dopSrcX, this._dopSrcY, 9, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.fillStyle = WavesSim.BG;
|
||||
ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('S', this._dopSrcX, this._dopSrcY);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
/* observer dot */
|
||||
ctx.save();
|
||||
ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = WavesSim.P;
|
||||
ctx.beginPath(); ctx.arc(this._dopObsX, this._dopObsY, 7, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.fillStyle = WavesSim.BG;
|
||||
ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('O', this._dopObsX, this._dopObsY);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
/* HUD */
|
||||
const hudX = 14, hudY = 20;
|
||||
ctx.fillStyle = 'rgba(13,13,26,0.72)';
|
||||
ctx.beginPath(); ctx.roundRect(hudX, hudY, 178, 72, 8); ctx.fill();
|
||||
ctx.font = "600 10px 'Manrope',sans-serif"; ctx.textAlign = 'left';
|
||||
const rows = [
|
||||
{ c: WavesSim.G, t: 'f₀ = ' + f0.toFixed(1) + ' Гц' },
|
||||
{ c: WavesSim.C, t: 'fᵒᵇˢ = ' + fObs.toFixed(1) + ' Гц' },
|
||||
{ c: WavesSim.P, t: 'Mach = ' + mach.toFixed(2) + (mach >= 1 ? ' [удар. волна]' : '') },
|
||||
];
|
||||
rows.forEach((r, i) => {
|
||||
ctx.fillStyle = r.c;
|
||||
ctx.fillText(r.t, hudX + 10, hudY + 18 + i * 18);
|
||||
});
|
||||
|
||||
/* drag hint */
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.22)';
|
||||
ctx.font = "500 9px 'Manrope',sans-serif"; ctx.textAlign = 'center';
|
||||
ctx.fillText('Перетащи S (источник) или O (наблюдатель)', W / 2, H - 10);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
БИЕНИЯ
|
||||
══════════════════════════════════════ */
|
||||
|
||||
_beatsDraw(ctx, W, H) {
|
||||
const PL = 48, PR = 20, PT = 60, PB = 40;
|
||||
const cw = W - PL - PR;
|
||||
const ch = H - PT - PB;
|
||||
const cy = PT + ch / 2;
|
||||
|
||||
this._grid(ctx, PL, PR, PT, PB, W, H);
|
||||
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
|
||||
|
||||
const f1 = this._beatsF1;
|
||||
const f2 = this._beatsF2;
|
||||
const fBeat = Math.abs(f1 - f2);
|
||||
const fAvg = (f1 + f2) / 2;
|
||||
const TBeat = fBeat > 0 ? 1 / fBeat : Infinity;
|
||||
|
||||
/* draw time window that spans ~3 beat periods (or 0.1s if no beat) */
|
||||
const winS = TBeat < Infinity ? Math.min(TBeat * 3, 2) : 0.12;
|
||||
const tOff = this._t % (winS > 0 ? winS : 1); /* scroll slowly */
|
||||
const A = Math.max(4, Math.min(ch / 2 - 4, 60));
|
||||
|
||||
/* sum waveform */
|
||||
ctx.save();
|
||||
ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12;
|
||||
ctx.strokeStyle = WavesSim.P; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (let px = 0; px <= cw; px++) {
|
||||
const t_s = (px / cw) * winS + tOff;
|
||||
const y = A * Math.cos(2 * Math.PI * f1 * t_s) +
|
||||
A * Math.cos(2 * Math.PI * f2 * t_s);
|
||||
const py = cy - y / 2; /* /2 because sum can reach 2A */
|
||||
px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py);
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
|
||||
/* envelope */
|
||||
ctx.save();
|
||||
ctx.strokeStyle = WavesSim.G; ctx.lineWidth = 1.4; ctx.globalAlpha = 0.6;
|
||||
ctx.setLineDash([6, 4]);
|
||||
for (const sign of [1, -1]) {
|
||||
ctx.beginPath();
|
||||
for (let px = 0; px <= cw; px++) {
|
||||
const t_s = (px / cw) * winS + tOff;
|
||||
const env = 2 * A * Math.abs(Math.cos(Math.PI * fBeat * t_s));
|
||||
const py = cy - sign * env / 2;
|
||||
px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
/* individual waves (dimmed) */
|
||||
const drawSingle = (f, color) => {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.3; ctx.strokeStyle = color; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let px = 0; px <= cw; px++) {
|
||||
const t_s = (px / cw) * winS + tOff;
|
||||
const py = cy - A * Math.cos(2 * Math.PI * f * t_s);
|
||||
px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py);
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
};
|
||||
drawSingle(f1, WavesSim.V);
|
||||
drawSingle(f2, WavesSim.C);
|
||||
|
||||
/* HUD */
|
||||
ctx.fillStyle = 'rgba(13,13,26,0.72)';
|
||||
ctx.beginPath(); ctx.roundRect(PL + 6, PT - 52, 220, 48, 8); ctx.fill();
|
||||
ctx.font = "600 10px 'Manrope',sans-serif"; ctx.textAlign = 'left';
|
||||
const hudRows = [
|
||||
{ c: WavesSim.V, t: 'f₁ = ' + f1.toFixed(1) + ' Гц' },
|
||||
{ c: WavesSim.C, t: 'f₂ = ' + f2.toFixed(1) + ' Гц' },
|
||||
{ c: WavesSim.G, t: 'fбиет = ' + fBeat.toFixed(2) + ' Гц Tбиет = ' + (TBeat < Infinity ? TBeat.toFixed(3) : '∞') + ' с' },
|
||||
];
|
||||
hudRows.forEach((r, i) => {
|
||||
ctx.fillStyle = r.c;
|
||||
ctx.fillText(r.t, PL + 16, PT - 36 + i * 16);
|
||||
});
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.28)';
|
||||
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
|
||||
ctx.fillText('Биения: fбиет = |f₁ − f₂|', PL, H - 8);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
СПЕКТР (ДПФ)
|
||||
══════════════════════════════════════ */
|
||||
|
||||
_dft(signal) {
|
||||
/* Real-valued DFT, returns magnitude array of length N/2 */
|
||||
const N = signal.length;
|
||||
const half = Math.floor(N / 2);
|
||||
const mag = new Float32Array(half);
|
||||
for (let k = 0; k < half; k++) {
|
||||
let re = 0, im = 0;
|
||||
const angle = (2 * Math.PI * k) / N;
|
||||
for (let n = 0; n < N; n++) {
|
||||
re += signal[n] * Math.cos(angle * n);
|
||||
im -= signal[n] * Math.sin(angle * n);
|
||||
}
|
||||
mag[k] = Math.sqrt(re * re + im * im) / N;
|
||||
}
|
||||
return mag;
|
||||
}
|
||||
|
||||
_spectrumDraw(ctx, W, H) {
|
||||
const PL = 48, PR = 20, PT = 40, PB = 60;
|
||||
const cw = W - PL - PR;
|
||||
const ch = H - PT - PB;
|
||||
|
||||
/* build signal from components */
|
||||
const N = 256;
|
||||
const fs = 100; /* sample rate Hz */
|
||||
const signal = new Float32Array(N);
|
||||
const comps = this._specComponents;
|
||||
for (let n = 0; n < N; n++) {
|
||||
let val = 0;
|
||||
for (const c of comps) val += (c.A / 90) * Math.cos(2 * Math.PI * c.f * n / fs + this._t);
|
||||
signal[n] = val;
|
||||
}
|
||||
|
||||
/* DFT */
|
||||
const mag = this._dft(signal);
|
||||
const half = mag.length;
|
||||
const df = fs / N; /* Hz per bin */
|
||||
const maxF = fs / 2; /* Nyquist */
|
||||
|
||||
/* find max for normalisation */
|
||||
let maxMag = 0;
|
||||
for (let k = 0; k < half; k++) if (mag[k] > maxMag) maxMag = mag[k];
|
||||
if (maxMag < 1e-9) maxMag = 1;
|
||||
|
||||
/* axes */
|
||||
this._axisLine(ctx, PL, PR, PT, PB, W, H, PT + ch);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let gx = PL; gx <= PL + cw; gx += 40) { ctx.moveTo(gx, PT); ctx.lineTo(gx, PT + ch); }
|
||||
for (let gy = PT; gy <= PT + ch; gy += 28) { ctx.moveTo(PL, gy); ctx.lineTo(PL + cw, gy); }
|
||||
ctx.stroke();
|
||||
|
||||
/* frequency axis labels */
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.font = "500 9px 'Manrope',sans-serif"; ctx.textAlign = 'center';
|
||||
const nLabels = Math.min(10, Math.floor(cw / 40));
|
||||
for (let i = 0; i <= nLabels; i++) {
|
||||
const f = (maxF * i) / nLabels;
|
||||
const x = PL + cw * i / nLabels;
|
||||
ctx.fillText(f.toFixed(0) + 'Hz', x, PT + ch + 14);
|
||||
}
|
||||
ctx.fillText('Частота', PL + cw / 2, H - 4);
|
||||
ctx.save(); ctx.translate(PL - 32, PT + ch / 2); ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText('Амплитуда', 0, 0); ctx.restore();
|
||||
|
||||
/* bars */
|
||||
const barW = Math.max(1, cw / half - 1);
|
||||
for (let k = 0; k < half; k++) {
|
||||
const norm = mag[k] / maxMag;
|
||||
const bH = norm * ch;
|
||||
const bx = PL + k * (cw / half);
|
||||
const color = norm > 0.5 ? WavesSim.G : WavesSim.V;
|
||||
ctx.save();
|
||||
if (norm > 0.5) { ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 10; }
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.3 + norm * 0.7;
|
||||
ctx.fillRect(bx, PT + ch - bH, barW, bH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* label peaks — bins where this bin's magnitude > neighbors & > 5% */
|
||||
const fmtF = f => f.toFixed(1) + 'Hz';
|
||||
ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center';
|
||||
for (let k = 1; k < half - 1; k++) {
|
||||
if (mag[k] > mag[k - 1] && mag[k] > mag[k + 1] && mag[k] / maxMag > 0.05) {
|
||||
const bx = PL + k * (cw / half) + barW / 2;
|
||||
const bH = (mag[k] / maxMag) * ch;
|
||||
ctx.save();
|
||||
ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 6;
|
||||
ctx.fillStyle = WavesSim.G;
|
||||
ctx.fillText(fmtF(k * df), bx, PT + ch - bH - 5);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/* components list */
|
||||
const listX = PL + 6, listY = PT + 6;
|
||||
ctx.fillStyle = 'rgba(13,13,26,0.7)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(listX, listY, 140, Math.min(comps.length * 16 + 8, ch - 12), 6);
|
||||
ctx.fill();
|
||||
ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left';
|
||||
comps.forEach((c, i) => {
|
||||
ctx.fillStyle = i % 2 === 0 ? WavesSim.V : WavesSim.C;
|
||||
ctx.fillText('f=' + c.f.toFixed(0) + 'Hz A=' + c.A, listX + 8, listY + 14 + i * 16);
|
||||
});
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.28)';
|
||||
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
|
||||
ctx.fillText('ДПФ: N=' + N + ', fs=' + fs + 'Hz', PL, PT - 12);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
ВСПОМОГАТЕЛЬНЫЕ
|
||||
══════════════════════════════════════ */
|
||||
@@ -452,6 +843,56 @@ class WavesSim {
|
||||
ctx.textAlign = 'left'; ctx.fillText('x', W - PR + 8, H - PB + 4);
|
||||
}
|
||||
|
||||
/* Spectrum: add a component at current _specNewF */
|
||||
specAddComponent() {
|
||||
const f = this._specNewF;
|
||||
const A = 60;
|
||||
if (this._specComponents.length < 12)
|
||||
this._specComponents.push({ f, A });
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* Spectrum: clear all components */
|
||||
specClear() {
|
||||
this._specComponents = [];
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* Doppler: attach mouse/touch drag for source and observer */
|
||||
dopAttachDrag(canvas) {
|
||||
const pos = e => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const src = e.touches ? e.touches[0] : e;
|
||||
return { x: (src.clientX - r.left) * (this._W / r.width),
|
||||
y: (src.clientY - r.top) * (this._H / r.height) };
|
||||
};
|
||||
const hitTest = p => {
|
||||
const dS = Math.hypot(p.x - this._dopSrcX, p.y - this._dopSrcY);
|
||||
const dO = Math.hypot(p.x - this._dopObsX, p.y - this._dopObsY);
|
||||
if (dS < 18) return 'src';
|
||||
if (dO < 18) return 'obs';
|
||||
return null;
|
||||
};
|
||||
const start = e => {
|
||||
const p = pos(e); this._dopDrag = hitTest(p);
|
||||
if (this._dopDrag) e.preventDefault();
|
||||
};
|
||||
const move = e => {
|
||||
if (!this._dopDrag) return;
|
||||
e.preventDefault();
|
||||
const p = pos(e);
|
||||
if (this._dopDrag === 'src') { this._dopSrcX = p.x; this._dopSrcY = p.y; }
|
||||
else { this._dopObsX = p.x; this._dopObsY = p.y; }
|
||||
};
|
||||
const end = () => { this._dopDrag = null; };
|
||||
canvas.addEventListener('mousedown', start);
|
||||
canvas.addEventListener('mousemove', move);
|
||||
canvas.addEventListener('mouseup', end);
|
||||
canvas.addEventListener('touchstart', start, { passive: false });
|
||||
canvas.addEventListener('touchmove', move, { passive: false });
|
||||
canvas.addEventListener('touchend', end);
|
||||
}
|
||||
|
||||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||||
}
|
||||
|
||||
@@ -478,9 +919,15 @@ class WavesSim {
|
||||
function wavesMode(mode, btn) {
|
||||
document.querySelectorAll('.wave-mode-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
document.getElementById('waves-w2-section').style.display = mode === 'superposition' ? '' : 'none';
|
||||
document.getElementById('waves-n-section').style.display = mode === 'standing' ? '' : 'none';
|
||||
if (wavesSim) wavesSim.setMode(mode);
|
||||
document.getElementById('waves-w2-section').style.display = mode === 'superposition' ? '' : 'none';
|
||||
document.getElementById('waves-n-section').style.display = mode === 'standing' ? '' : 'none';
|
||||
document.getElementById('waves-doppler-section').style.display = mode === 'doppler' ? '' : 'none';
|
||||
document.getElementById('waves-beats-section').style.display = mode === 'beats' ? '' : 'none';
|
||||
document.getElementById('waves-spectrum-section').style.display = mode === 'spectrum' ? '' : 'none';
|
||||
if (wavesSim) {
|
||||
wavesSim.setMode(mode);
|
||||
if (mode === 'doppler') wavesSim.dopAttachDrag(document.getElementById('waves-canvas'));
|
||||
}
|
||||
}
|
||||
|
||||
function wavesParam(name, val) {
|
||||
@@ -492,10 +939,22 @@ class WavesSim {
|
||||
if (name === 'A2') el('waves-A2-val', v);
|
||||
if (name === 'f2') el('waves-f2-val', v.toFixed(1) + ' Гц');
|
||||
if (name === 'phi2') el('waves-phi2-val', v.toFixed(1));
|
||||
if (name === 'speed') el('waves-speed-val', '\u00d7' + v.toFixed(1));
|
||||
if (name === 'speed') el('waves-speed-val', '\u00d7' + v.toFixed(1));
|
||||
if (name === 'dopVs') el('waves-dopVs-val', v.toFixed(2) + 'c');
|
||||
if (name === 'beatsF1') el('waves-beatsF1-val', v.toFixed(0) + ' \u0413\u0446');
|
||||
if (name === 'beatsF2') el('waves-beatsF2-val', v.toFixed(0) + ' \u0413\u0446');
|
||||
if (name === 'specNewF') el('waves-specNewF-val', v.toFixed(0) + ' \u0413\u0446');
|
||||
if (wavesSim) wavesSim.setParams({ [name]: v });
|
||||
}
|
||||
|
||||
function wavesSpecAdd() {
|
||||
if (wavesSim) wavesSim.specAddComponent();
|
||||
}
|
||||
|
||||
function wavesSpecClear() {
|
||||
if (wavesSim) wavesSim.specClear();
|
||||
}
|
||||
|
||||
function wavesN(n, btn) {
|
||||
document.querySelectorAll('.wave-n-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
|
||||
Reference in New Issue
Block a user