Files
Learn_System/frontend/js/labs/waves.js
T
Maxim Dolgolyov 8f30a8cef6 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>
2026-05-23 12:48:14 +03:00

1007 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════
WavesSim v3 — Волны и звук
Modes: transverse | longitudinal | superposition | standing
doppler | beats | spectrum
─────────────────────────────────────────── */
class WavesSim {
static BG = '#0D0D1A';
static FONT = "700 12px 'Manrope',sans-serif";
static V = '#9B5DE5'; /* violet */
static C = '#06D6E0'; /* cyan */
static P = '#F15BB5'; /* pink */
static G = '#FFD166'; /* gold */
constructor(canvas) {
this._c = canvas;
this._ctx = canvas.getContext('2d');
this._dpr = 1;
this._W = 0;
this._H = 0;
this._mode = 'transverse';
this._t = 0;
this._last = null;
this._raf = null;
this._paused = true;
this._A1 = 50; this._f1 = 1.0; this._phi1 = 0;
this._A2 = 40; this._f2 = 1.5; this._phi2 = 0;
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;
}
/* ── публичное API ── */
fit() {
const par = this._c.parentElement;
const dpr = window.devicePixelRatio || 1;
const w = par.clientWidth || 600;
const h = par.clientHeight || 400;
this._c.width = Math.round(w * dpr);
this._c.height = Math.round(h * dpr);
this._dpr = dpr;
this._W = w;
this._H = h;
if (this._resizeObs) this._resizeObs.disconnect();
this._resizeObs = new ResizeObserver(() => this.fit());
this._resizeObs.observe(par);
this.draw();
}
setMode(mode) {
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();
}
getParams() {
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,
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();
}
play() {
this._paused = false;
this._last = null;
if (!this._raf) this._raf = requestAnimationFrame(t => this._tick(t));
}
pause() {
this._paused = true;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
reset() { this._t = 0; this._last = null; this.draw(); this._emit(); }
info() {
const v = (this._W || 600) / 3;
return {
T: (1 / this._f1).toFixed(2),
lambda: (v / this._f1).toFixed(0),
v: v.toFixed(0),
f1: this._f1
};
}
/* ── анимационный цикл ── */
_tick(ts) {
if (!this._paused) {
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 {
this._raf = null;
}
this.draw();
this._emit();
}
/* ── главный draw ── */
draw() {
const { _ctx: ctx, _W: W, _H: H, _dpr: dpr } = this;
if (!W || !H) return;
/* сбрасываем трансформ + заливаем фон */
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = WavesSim.BG;
ctx.fillRect(0, 0, W, H);
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 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);
}
/* ══════════════════════════════════════
ПОПЕРЕЧНАЯ ВОЛНА
══════════════════════════════════════ */
_transvDraw(ctx, W, H) {
const PL = 48, PR = 20, PT = 50, PB = 48;
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 A = Math.max(4, Math.min(this._A1, ch / 2 - 8));
const v = cw / 3;
const lam = v / this._f1;
const k = (2 * Math.PI) / lam;
const om = 2 * Math.PI * this._f1;
const t = this._t;
const phi = this._phi1;
const y = x => A * Math.sin(om * t - k * (x - PL) + phi);
/* волновая кривая */
ctx.save();
ctx.shadowColor = WavesSim.V;
ctx.shadowBlur = 16;
ctx.strokeStyle = WavesSim.V;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let x = PL; x <= PL + cw; x += 1) {
const py = cy + y(x);
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke();
ctx.restore();
/* частицы */
const step = Math.max(12, Math.floor(lam / 10));
for (let x = PL + step * 0.5; x < PL + cw; x += step) {
const py = cy + y(x);
const norm = Math.abs(y(x)) / (A || 1);
ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, py);
ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1; ctx.stroke();
ctx.save();
ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(x, py, 4, 0, 6.28);
ctx.fillStyle = `rgba(155,93,229,${(0.4 + 0.6 * norm).toFixed(2)})`; ctx.fill();
ctx.restore();
}
/* выделенная частица */
const hx = PL + Math.min(lam * 0.5, cw * 0.22);
const hy = cy + y(hx);
ctx.save();
ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18;
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.28);
ctx.fillStyle = WavesSim.G; ctx.fill();
ctx.restore();
ctx.save();
ctx.setLineDash([3, 4]);
ctx.strokeStyle = 'rgba(255,209,102,0.22)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(hx, cy - A); ctx.lineTo(hx, cy + A); ctx.stroke();
ctx.restore();
/* аннотация длины волны */
const ld = Math.min(lam, cw - 16);
if (ld > 36) {
const ay = cy + A + 26;
ctx.save();
ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(PL + 8, ay); ctx.lineTo(PL + 8 + ld, ay);
ctx.moveTo(PL + 13, ay - 4); ctx.lineTo(PL + 8, ay); ctx.lineTo(PL + 13, ay + 4);
ctx.moveTo(PL + 8 + ld - 5, ay - 4); ctx.lineTo(PL + 8 + ld, ay); ctx.lineTo(PL + 8 + ld - 5, ay + 4);
ctx.stroke();
ctx.fillStyle = WavesSim.C; ctx.textAlign = 'center';
ctx.font = "700 10px 'Manrope',sans-serif";
ctx.fillText('\u03bb = ' + ld.toFixed(0), PL + 8 + ld / 2, ay - 5);
ctx.restore();
}
/* аннотация амплитуды */
if (A > 16) {
const ax = PL - 20;
ctx.save();
ctx.strokeStyle = 'rgba(241,91,181,0.7)'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(ax, cy); ctx.lineTo(ax, cy - A);
ctx.moveTo(ax - 4, cy - A + 5); ctx.lineTo(ax, cy - A); ctx.lineTo(ax + 4, cy - A + 5);
ctx.moveTo(ax - 3, cy - 3); ctx.lineTo(ax, cy); ctx.lineTo(ax + 3, cy - 3);
ctx.stroke();
ctx.fillStyle = WavesSim.P; ctx.textAlign = 'center';
ctx.font = "700 10px 'Manrope',sans-serif";
ctx.save(); ctx.translate(ax - 12, cy - A / 2); ctx.rotate(-Math.PI / 2);
ctx.fillText('A', 0, 0); ctx.restore();
ctx.restore();
}
/* подпись */
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
ctx.fillText('y = A sin(\u03c9t \u2212 kx + \u03c6)', PL, PT - 14);
}
/* ══════════════════════════════════════
ПРОДОЛЬНАЯ ВОЛНА
══════════════════════════════════════ */
_longDraw(ctx, W, H) {
const PL = 24, PR = 24, PT = 50, PB = 60;
const cw = W - PL - PR;
const ch = H - PT - PB;
const nRows = 5;
const rowH = ch / nRows;
const nPart = Math.max(20, Math.floor(cw / 10));
const dx0 = cw / nPart;
const v = cw / 3;
const lam = v / this._f1;
const k = (2 * Math.PI) / lam;
const om = 2 * Math.PI * this._f1;
const A = Math.min(this._A1 * 0.5, lam / 4, rowH * 0.36);
const t = this._t;
const phi = this._phi1;
/* ряды частиц */
for (let row = 0; row < nRows; row++) {
const cy = PT + rowH * (row + 0.5);
for (let i = 0; i < nPart; i++) {
const x0 = PL + (i + 0.5) * dx0;
const phase = om * t - k * (x0 - PL) + phi;
const disp = A * Math.sin(phase);
const xd = Math.max(PL + 1, Math.min(PL + cw - 1, x0 + disp));
const dens = 1 / Math.max(0.15, 1 + (-A * k * Math.cos(phase)));
const alpha = Math.max(0.1, Math.min(0.95, dens * 0.55));
ctx.beginPath(); ctx.arc(xd, cy, 3, 0, 6.28);
ctx.fillStyle = `rgba(155,93,229,${alpha.toFixed(2)})`; ctx.fill();
}
}
/* график давления */
const pTop = PT + ch + 10;
const pH = H - pTop - 8;
if (pH > 20) {
const axY = pTop + pH / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(PL, axY); ctx.lineTo(PL + cw, axY); ctx.stroke();
ctx.setLineDash([]);
ctx.save();
ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 8;
ctx.strokeStyle = WavesSim.C; ctx.lineWidth = 2;
ctx.beginPath();
for (let x = PL; x <= PL + cw; x += 1) {
const py = axY - Math.cos(om * t - k * (x - PL) + phi) * pH * 0.4;
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke(); ctx.restore();
ctx.fillStyle = WavesSim.C; ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left';
ctx.fillText('P(x,t)', PL + 2, pTop + 11);
}
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
ctx.fillText('Продольная волна', PL, PT - 16);
}
/* ══════════════════════════════════════
СУПЕРПОЗИЦИЯ
══════════════════════════════════════ */
_superDraw(ctx, W, H) {
const PL = 48, PR = 20, PT = 70, PB = 48;
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 v = cw / 3;
const t = this._t;
const mk = (f, A) => {
const lam = v / f, k = (2 * Math.PI) / lam, om = 2 * Math.PI * f;
const amp = Math.max(4, Math.min(A, ch / 2 - 8));
return { k, om, amp };
};
const w1 = mk(this._f1, this._A1);
const w2 = mk(this._f2, this._A2);
const y1 = x => w1.amp * Math.sin(w1.om * t - w1.k * (x - PL) + this._phi1);
const y2 = x => w2.amp * Math.sin(w2.om * t - w2.k * (x - PL) + this._phi2);
const yR = x => y1(x) + y2(x);
this._waveLine(ctx, PL, cw, cy, y1, WavesSim.V, 1.5, 0.45, false);
this._waveLine(ctx, PL, cw, cy, y2, WavesSim.C, 1.5, 0.45, false);
this._waveLine(ctx, PL, cw, cy, yR, WavesSim.P, 2.8, 1.0, true);
/* легенда */
const items = [
{ c: WavesSim.V, txt: 'y\u2081 = A\u2081 sin(\u03c9\u2081t \u2212 k\u2081x + \u03c6\u2081)' },
{ c: WavesSim.C, txt: 'y\u2082 = A\u2082 sin(\u03c9\u2082t \u2212 k\u2082x + \u03c6\u2082)' },
{ c: WavesSim.P, txt: 'y = y\u2081 + y\u2082' },
];
ctx.font = "600 9px 'Manrope',sans-serif";
items.forEach((it, i) => {
const lx = PL + 6, ly = PT - 56 + i * 18;
ctx.save();
ctx.shadowColor = it.c; ctx.shadowBlur = 8;
ctx.fillStyle = it.c;
ctx.beginPath(); ctx.arc(lx + 4, ly + 4, 3.5, 0, 6.28); ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textAlign = 'left';
ctx.fillText(it.txt, lx + 13, ly + 8);
});
}
/* ══════════════════════════════════════
СТОЯЧАЯ ВОЛНА
══════════════════════════════════════ */
_standDraw(ctx, W, H) {
const PL = 48, PR = 20, PT = 50, PB = 48;
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 n = this._n;
const k = (n * Math.PI) / cw;
const om = 2 * Math.PI * this._f1;
const A = Math.max(4, Math.min(this._A1, ch / 2 - 10));
const t = this._t;
/* прямая и обратная (тусклые) */
this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t - k * (x - PL)), WavesSim.V, 1.0, 0.25, false);
this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t + k * (x - PL) + Math.PI), WavesSim.C, 1.0, 0.25, false);
/* огибающая */
ctx.save();
ctx.globalAlpha = 0.12;
ctx.fillStyle = WavesSim.V;
ctx.beginPath(); ctx.moveTo(PL, cy);
for (let x = PL; x <= PL + cw; x++) ctx.lineTo(x, cy - 2 * A * Math.abs(Math.sin(k * (x - PL))));
for (let x = PL + cw; x >= PL; x--) ctx.lineTo(x, cy + 2 * A * Math.abs(Math.sin(k * (x - PL))));
ctx.closePath(); ctx.fill(); ctx.restore();
/* стоячая волна */
const cosT = Math.cos(om * t + this._phi1);
this._waveLine(ctx, PL, cw, cy, x => 2 * A * Math.sin(k * (x - PL)) * cosT, WavesSim.G, 2.8, 1.0, true);
/* узлы (cyan) */
ctx.save(); ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 10; ctx.fillStyle = WavesSim.C;
for (let m = 0; m <= n; m++) {
ctx.beginPath(); ctx.arc(PL + m * cw / n, cy, 5, 0, 6.28); ctx.fill();
}
ctx.restore();
/* пучности (pink) */
ctx.save(); ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12; ctx.fillStyle = WavesSim.P;
for (let m = 0; m < n; m++) {
const ax = PL + (m + 0.5) * cw / n;
const ay = cy + 2 * A * Math.sin(k * (ax - PL)) * cosT;
ctx.beginPath(); ctx.arc(ax, ay, 5, 0, 6.28); ctx.fill();
}
ctx.restore();
/* легенда */
const lx = W - PR - 128, ly = PT - 20;
ctx.font = "600 9px 'Manrope',sans-serif";
[{ c: WavesSim.C, t: 'Узел (y\u22610)', dy: 0 }, { c: WavesSim.P, t: 'Пучность', dy: 16 }].forEach(r => {
ctx.save(); ctx.shadowColor = r.c; ctx.shadowBlur = 8; ctx.fillStyle = r.c;
ctx.beginPath(); ctx.arc(lx + 5, ly + r.dy + 5, 4, 0, 6.28); ctx.fill(); ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left';
ctx.fillText(r.t, lx + 14, ly + r.dy + 9);
});
ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
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);
}
/* ══════════════════════════════════════
ВСПОМОГАТЕЛЬНЫЕ
══════════════════════════════════════ */
_waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) {
ctx.save();
ctx.globalAlpha = alpha;
if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
ctx.strokeStyle = color; ctx.lineWidth = lw;
ctx.beginPath();
for (let x = PL; x <= PL + cw; x += 1) {
const py = cy + fn(x);
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke(); ctx.restore();
}
_grid(ctx, PL, PR, PT, PB, W, H) {
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let y = PT; y <= H - PB; y += 28) { ctx.moveTo(PL, y); ctx.lineTo(W - PR, y); }
for (let x = PL; x <= W - PR; x += 40) { ctx.moveTo(x, PT); ctx.lineTo(x, H - PB); }
ctx.stroke();
}
_axisLine(ctx, PL, PR, PT, PB, W, H, cy) {
ctx.save();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PL, cy); ctx.lineTo(W - PR, cy); ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PL, PT - 6); ctx.lineTo(PL, H - PB);
ctx.moveTo(PL, H - PB); ctx.lineTo(W - PR + 6, H - PB);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.font = "600 9px 'Manrope',sans-serif";
ctx.textAlign = 'right'; ctx.fillText('y', PL - 4, PT);
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()); }
}
/* ─── lab UI init ─────────────────────────────────── */
function _openWaves() {
document.getElementById('sim-topbar-title').textContent = 'Волны и звук';
document.getElementById('ctrl-waves').style.display = '';
_simShow('sim-waves');
_registerSimState('waves', () => wavesSim?.getParams(),
st => { if (wavesSim) { if (st.mode) wavesSim.setMode(st.mode); wavesSim.setParams(st); } });
if (_embedMode) _startStateEmit('waves');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!wavesSim) {
wavesSim = new WavesSim(document.getElementById('waves-canvas'));
wavesSim.onUpdate = _wavesUpdateUI;
}
wavesSim.fit();
wavesSim.reset();
wavesSim.play();
_wavesUpdateUI(wavesSim.info());
}));
}
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';
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) {
const v = parseFloat(val);
const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
if (name === 'A1') el('waves-A1-val', v);
if (name === 'f1') el('waves-f1-val', v.toFixed(1) + ' Гц');
if (name === 'phi1') el('waves-phi1-val', v.toFixed(1));
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 === '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');
if (wavesSim) wavesSim.setParams({ n });
}
function wavesPreset(name) {
const presets = {
constructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 0 },
destructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 3.14 },
beats: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.3, phi2: 0 },
};
const p = presets[name]; if (!p) return;
document.getElementById('sl-waves-A1').value = p.A1;
document.getElementById('sl-waves-f1').value = p.f1;
document.getElementById('sl-waves-phi1').value = p.phi1;
document.getElementById('sl-waves-A2').value = p.A2;
document.getElementById('sl-waves-f2').value = p.f2;
document.getElementById('sl-waves-phi2').value = p.phi2;
document.getElementById('waves-A1-val').textContent = p.A1;
document.getElementById('waves-f1-val').textContent = p.f1.toFixed(1) + ' Гц';
document.getElementById('waves-phi1-val').textContent = p.phi1.toFixed(1);
document.getElementById('waves-A2-val').textContent = p.A2;
document.getElementById('waves-f2-val').textContent = p.f2.toFixed(1) + ' Гц';
document.getElementById('waves-phi2-val').textContent = p.phi2.toFixed(1);
if (wavesSim) wavesSim.setParams({ A1: p.A1, f1: p.f1, phi1: p.phi1, A2: p.A2, f2: p.f2, phi2: p.phi2 });
}
function wavesPlayPause() {
if (!wavesSim) return;
const btn = document.getElementById('waves-play-btn');
if (wavesSim._paused) {
wavesSim.play();
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
} else {
wavesSim.pause();
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
}
}
function _wavesUpdateUI(info) {
const v = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
v('wavesbar-T', info.T);
v('wavesbar-lam', info.lambda);
v('wavesbar-v', info.v);
v('wavesbar-f', (+info.f1).toFixed(1));
}
/* ── crystal lattice (3D) ── */