Files
Learn_System/frontend/js/labs/waves.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

1077 lines
41 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 }];
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
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);
if (window.LabFX) LabFX.particles.update(dt);
/* beats: play tick at envelope peaks (throttle to beat period) */
if (this._mode === 'beats') {
const fBeat = Math.abs(this._beatsF1 - this._beatsF2);
if (fBeat > 0) {
const TBeat = 1 / fBeat;
const beatPhase = this._t % TBeat;
if (!this._lastBeatTick || this._t - this._lastBeatTick >= TBeat * 0.95) {
if (beatPhase < dt * this._speed + 0.02) {
if (window.LabFX) LabFX.sound.play('tick', { pitch: 0.5, volume: 0.15 });
this._lastBeatTick = this._t;
}
}
}
}
}
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);
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ══════════════════════════════════════
ПОПЕРЕЧНАЯ ВОЛНА
══════════════════════════════════════ */
_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);
/* волновая кривая */
const _drawTransvWave = () => {
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();
};
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, _drawTransvWave, { color: WavesSim.V, intensity: 5 });
} else {
ctx.save();
ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 16;
_drawTransvWave();
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;
});
/* LabFX: Mach shock particles when vs >= c */
const mach = vsPx / c_px;
if (window.LabFX) {
if (mach >= 1.0 && !this._dopWasMach) {
this._dopWasMach = true;
LabFX.sound.play('spark', { volume: 0.4 });
LabFX.particles.emit({
ctx: this._ctx, x: this._dopSrcX, y: this._dopSrcY,
count: 20, color: '#FF6B35', speed: 120,
spread: Math.PI * 2, angle: 0, gravity: 80,
life: 600, shape: 'spark', glow: true, size: 3,
});
} else if (mach < 1.0) {
this._dopWasMach = false;
}
/* haptic while dragging src (throttle 100ms) */
if (this._dopDrag === 'src') {
const now2 = performance.now();
if (!this._dopHapticLast || now2 - this._dopHapticLast >= 100) {
LabFX.haptic(5);
this._dopHapticLast = now2;
}
}
}
}
_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) {
const drawFn = () => {
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.save();
ctx.globalAlpha = alpha;
if (glow && window.LabFX) {
LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 5 });
} else {
if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
drawFn();
}
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 });
if (window.LabFX) {
LabFX.sound.play('chime', { pitch: Math.pow(2, f / 12), volume: 0.3 });
/* emit dust at approximate peak position on spectrum */
const peakX = this._W ? (this._W * 0.08) + (f / 50) * (this._W * 0.84) : 200;
const peakY = this._H ? this._H * 0.3 : 100;
LabFX.particles.emit({
ctx: this._ctx, x: peakX, y: peakY,
count: 8, color: '#FFD166', speed: 30,
spread: Math.PI, angle: -Math.PI / 2, gravity: 50,
life: 400, shape: 'dust', size: 2,
});
}
}
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) ── */