6afe928c0d
ФУНДАМЕНТ (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>
1077 lines
41 KiB
JavaScript
1077 lines
41 KiB
JavaScript
'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) ── */
|