'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 = ''; } else { wavesSim.pause(); btn.innerHTML = ''; } } 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) ── */