/* phys-fx.js — библиотека анимированных физических симуляций для Физики 11. * * Архитектура: * - Один глобальный requestAnimationFrame-цикл (Ticker). * - Каждая симуляция — класс с методами update(dt, t), render(). * - IntersectionObserver: симуляция приостанавливается, когда уходит из viewport. * - Чистый SVG (без Canvas, без WebGL, без зависимостей). * * Публичный API: window.PHYS = { util, Oscillogram, SpringMass, Pendulum, ... }. * * W0 — базовая инфраструктура + 3 компонента (Oscillogram, SpringMass, Pendulum). * Расширяется в W3 (электротехника), W5-W7 (оптика), W9-W14 (кванты, ядро). */ (function(){ 'use strict'; if (window.PHYS && window.PHYS.__installed) return; const P = window.PHYS = window.PHYS || {}; P.__installed = true; /* ============================================================ */ /* ГЛОБАЛЬНЫЙ ТАЙМЕР (один RAF на всю страницу) */ /* ============================================================ */ const Ticker = { t: 0, last: 0, subs: new Set(), running: false }; function tick(ts){ if (!Ticker.running) return; if (!Ticker.last) Ticker.last = ts; const dt = Math.min((ts - Ticker.last) / 1000, 0.1); // защита от лагов Ticker.last = ts; Ticker.t += dt; Ticker.subs.forEach(s => { if (!s.paused) { try { s.update(dt, Ticker.t); s.render && s.render(); } catch(e) {} } }); requestAnimationFrame(tick); } function startTicker(){ if (Ticker.running) return; Ticker.running = true; Ticker.last = 0; requestAnimationFrame(tick); } function stopTicker(){ Ticker.running = false; } /* ============================================================ */ /* УТИЛИТЫ */ /* ============================================================ */ const util = P.util = { subscribe(sim){ Ticker.subs.add(sim); startTicker(); }, unsubscribe(sim){ Ticker.subs.delete(sim); if (Ticker.subs.size === 0) stopTicker(); }, /* Создаёт IntersectionObserver, который ставит/снимает sim.paused */ observe(sim){ if (!sim.el || !window.IntersectionObserver) return; const io = new IntersectionObserver(entries => { entries.forEach(e => { sim.paused = !e.isIntersecting; }); }, { threshold: 0.05 }); io.observe(sim.el); sim._io = io; }, /* Безопасное удаление симуляции */ destroy(sim){ util.unsubscribe(sim); if (sim._io) { try { sim._io.disconnect(); } catch(e){} sim._io = null; } if (sim.el) sim.el.innerHTML = ''; }, /* Хелпер: создать SVG-обёртку с осями для графика */ svgFrame(w, h, opts){ opts = opts || {}; const bg = opts.bg || '#fafafa'; const border = opts.border || '1px solid #e2e8f0'; return ''; }, /* Двухмерные оси t (горизонтально) и y (вертикально). Возвращает функции toX/toY */ axes(W, H, pad, tMax, yRange){ const left = pad, right = W - pad, top = pad, bot = H - pad; const ux = (right - left) / tMax; const uy = (bot - top) / (yRange[1] - yRange[0]); function toX(t){ return left + t * ux; } function toY(y){ return bot - (y - yRange[0]) * uy; } /* SVG сетки + рамки */ let svg = ''; /* Вертикальные линии каждую секунду */ for (let s = 0; s <= tMax; s++) svg += ''; /* Горизонтальные линии */ const yStep = (yRange[1] - yRange[0]) / 4; for (let i = 0; i <= 4; i++){ const y = yRange[0] + i * yStep; svg += ''; } svg += ''; /* Ось t */ svg += ''; /* Ось y */ svg += ''; return { svg: svg, toX, toY, left, right, top, bot }; }, /* Создать ползунок-control под симуляцией. opts: { label, min, max, step, value, onChange } */ slider(opts){ const id = 'sl-' + Math.random().toString(36).slice(2,7); const html = '' + opts.label + '' + '' + '' + opts.value + (opts.unit || '') + '' + ''; return { html, id, wire(root){ const inp = root.querySelector('#' + id); const v = root.querySelector('#' + id + '-v'); if (!inp || !v) return; inp.addEventListener('input', () => { const val = parseFloat(inp.value); v.textContent = (opts.fmt ? opts.fmt(val) : val) + (opts.unit || ''); if (opts.onChange) opts.onChange(val); }); } }; } }; /* ============================================================ */ /* Oscillogram — гармонические колебания */ /* ============================================================ */ class Oscillogram { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 560; this.H = opts.height || 200; this.pad = opts.pad || 32; this.tWindow = opts.tWindow || 4; // секунд видно this.A = opts.A !== undefined ? opts.A : 1.0; this.omega = opts.omega !== undefined ? opts.omega : 2 * Math.PI; this.phi0 = opts.phi0 !== undefined ? opts.phi0 : 0; this.damping = opts.damping || 0; this.color = opts.color || '#dc2626'; this.label = opts.label || 'x(t)'; this.paused = false; this.t = 0; this.history = []; // [t, y] точки за последние tWindow секунд this._render(); util.subscribe(this); util.observe(this); } setA(v){ this.A = v; } setOmega(v){ this.omega = v; } setPhi(v){ this.phi0 = v; } setDamping(v){ this.damping = v; } reset(){ this.history = []; this.t = 0; } update(dt){ this.t += dt; const y = this.A * Math.exp(-this.damping * this.t) * Math.cos(this.omega * this.t + this.phi0); this.history.push([this.t, y]); while (this.history.length && this.history[0][0] < this.t - this.tWindow) this.history.shift(); } render(){ if (!this.el) return; const W = this.W, H = this.H, pad = this.pad; const tMin = Math.max(0, this.t - this.tWindow); const yRange = [-Math.max(1.05, this.A * 1.1), Math.max(1.05, this.A * 1.1)]; const ax = util.axes(W, H, pad, this.tWindow, yRange); let polyline = ''; if (this.history.length > 1){ const pts = this.history.map(([t, y]) => (ax.left + (t - tMin) * (ax.right - ax.left) / this.tWindow).toFixed(1) + ',' + ax.toY(y).toFixed(1)); polyline = ''; } /* Подпись y(t) */ const titleSvg = '' + this.label + ''; const svg = util.svgFrame(W, H) + ax.svg + polyline + titleSvg + ''; this.el.innerHTML = svg; } _render(){ this.render(); } } P.Oscillogram = Oscillogram; /* ============================================================ */ /* SpringMass — пружинный маятник (вертикальный) */ /* ============================================================ */ class SpringMass { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 240; this.H = opts.height || 280; this.m = opts.m !== undefined ? opts.m : 0.5; // кг this.k = opts.k !== undefined ? opts.k : 20; // Н/м this.A = opts.A !== undefined ? opts.A : 0.06; // м (амплитуда) this.color = opts.color || '#0891b2'; this.paused = false; this.t = 0; this._render(); util.subscribe(this); util.observe(this); } setMass(m){ this.m = Math.max(0.05, m); } setStiffness(k){ this.k = Math.max(1, k); } setAmplitude(A){ this.A = Math.max(0.005, A); } period(){ return 2 * Math.PI * Math.sqrt(this.m / this.k); } freq(){ return 1 / this.period(); } update(dt){ this.t += dt; } render(){ if (!this.el) return; const W = this.W, H = this.H; const T = this.period(); const omega = 2 * Math.PI / T; const A_px = 60; /* визуальная амплитуда в px */ const y0 = 90; /* y-координата равновесия груза в px */ const yCur = y0 + A_px * Math.cos(omega * this.t); /* Пружина: гармошка-зигзаг от крюка (y=20) до груза (y=yCur-18) */ const cx = W / 2, hookY = 20, massY = yCur, massR = 22; const coils = 10; const springTop = hookY; const springBot = massY - massR; const segH = (springBot - springTop) / (coils * 2); let path = 'M ' + cx + ' ' + springTop; for (let i = 0; i < coils; i++){ path += ' L ' + (cx - 14) + ' ' + (springTop + segH * (2 * i + 1)); path += ' L ' + (cx + 14) + ' ' + (springTop + segH * (2 * i + 2)); } path += ' L ' + cx + ' ' + springBot; /* Линейка справа */ const ruler = '' + '' + '' + '+A' + '' + '0' + '' + '-A' + ''; /* Период справа сверху */ const Tlabel = 'T = ' + T.toFixed(2) + ' с'; const svg = util.svgFrame(W, H, {bg:'#f8fafc'}) + '' + '' + '' + '' + '' + 'm' + ruler + Tlabel + ''; this.el.innerHTML = svg; } _render(){ this.render(); } } P.SpringMass = SpringMass; /* ============================================================ */ /* Pendulum — математический маятник */ /* ============================================================ */ class Pendulum { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 240; this.H = opts.height || 260; this.l = opts.l !== undefined ? opts.l : 1.0; // м this.g = opts.g !== undefined ? opts.g : 9.81; this.theta0 = opts.theta0 !== undefined ? opts.theta0 : Math.PI / 12; // начальный угол (рад) this.color = opts.color || '#0891b2'; this.paused = false; this.t = 0; this._render(); util.subscribe(this); util.observe(this); } setLength(l){ this.l = Math.max(0.1, l); } setG(g){ this.g = Math.max(0.5, g); } setTheta0(theta){ this.theta0 = Math.max(0.02, Math.min(Math.PI/4, theta)); } period(){ return 2 * Math.PI * Math.sqrt(this.l / this.g); } update(dt){ this.t += dt; } render(){ if (!this.el) return; const W = this.W, H = this.H; const T = this.period(); const omega = 2 * Math.PI / T; const theta = this.theta0 * Math.cos(omega * this.t); const cx = W / 2, hookY = 20; const Lpx = Math.min(160, H - 70); const bobR = 18; const bx = cx + Lpx * Math.sin(theta); const by = hookY + Lpx * Math.cos(theta); /* Дуга-траектория */ const arcR = Lpx; const arcStart = -this.theta0; const arcEnd = this.theta0; const aS = { x: cx + arcR * Math.sin(arcStart), y: hookY + arcR * Math.cos(arcStart) }; const aE = { x: cx + arcR * Math.sin(arcEnd), y: hookY + arcR * Math.cos(arcEnd) }; const largeArc = (arcEnd - arcStart) > Math.PI ? 1 : 0; const sweep = 1; const arc = ''; /* Вертикальная пунктирная ось */ const vert = ''; /* Подвес */ const string = ''; const bob = ''; /* Период */ const Tlabel = 'T = ' + T.toFixed(2) + ' с'; /* Подвес-крепление */ const hook = ''; const svg = util.svgFrame(W, H, {bg:'#f8fafc'}) + hook + vert + arc + string + bob + Tlabel + ''; this.el.innerHTML = svg; } _render(){ this.render(); } } P.Pendulum = Pendulum; /* ============================================================ */ /* EnergyView — превращения энергии при гарм. колебаниях */ /* Показывает W_к, W_п, W_мех=const на одном графике */ /* ============================================================ */ class EnergyView { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 560; this.H = opts.height || 240; this.pad = opts.pad || 36; this.A = opts.A !== undefined ? opts.A : 1.0; this.omega = opts.omega !== undefined ? opts.omega : 2 * Math.PI; this.tWindow = opts.tWindow || 4; this.paused = false; this.t = 0; this.history = []; // [t, Wk, Wp] util.subscribe(this); util.observe(this); this.render(); } setA(v){ this.A = v; this.history = []; } setOmega(v){ this.omega = v; this.history = []; } update(dt){ this.t += dt; /* Для x = A cos(ωt): v = -Aω sin(ωt) W_к = m v² / 2 = (1/2) m A² ω² sin²(ωt) W_п = k x² / 2 = (1/2) m ω² · A² cos²(ωt) (k = m ω²) В безразмерных: положим (1/2)mω²A² = 1 — тогда обе варьируются 0..1, сумма = 1 */ const c = Math.cos(this.omega * this.t); const s = Math.sin(this.omega * this.t); const Wp = c * c; const Wk = s * s; this.history.push([this.t, Wk, Wp]); while (this.history.length && this.history[0][0] < this.t - this.tWindow) this.history.shift(); } render(){ if (!this.el) return; const W = this.W, H = this.H, pad = this.pad; const tMin = Math.max(0, this.t - this.tWindow); const yRange = [0, 1.1]; const ax = util.axes(W, H, pad, this.tWindow, yRange); function path(idx, color, label){ if (this.history.length < 2) return ''; const pts = this.history.map(p => (ax.left + (p[0] - tMin) * (ax.right - ax.left) / this.tWindow).toFixed(1) + ',' + ax.toY(p[idx]).toFixed(1)); return ''; } const pK = path.call(this, 1, '#dc2626'); const pP = path.call(this, 2, '#16a34a'); /* W_мех = const = 1 (горизонтальная линия) */ const pM = ''; /* Легенда */ const legend = '' + '' + '' + 'W кинет.' + '' + 'W потенц.' + '' + 'W мех = const' + ''; const svg = util.svgFrame(W, H) + ax.svg + pM + pK + pP + legend + ''; this.el.innerHTML = svg; } } P.EnergyView = EnergyView; /* ============================================================ */ /* ResonanceCurve — резонансная кривая A(ω) при разных γ */ /* ============================================================ */ class ResonanceCurve { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 540; this.H = opts.height || 240; this.pad = opts.pad || 40; this.omega0 = opts.omega0 || 1.0; /* собственная частота (норм.) */ this.gamma = opts.gamma !== undefined ? opts.gamma : 0.15; this.omegaCur = opts.omegaCur !== undefined ? opts.omegaCur : 0.6; this.color = opts.color || '#7c3aed'; this.paused = false; this.render(); } setGamma(g){ this.gamma = Math.max(0.02, g); this.render(); } setOmegaCur(w){ this.omegaCur = Math.max(0.02, w); this.render(); } /* update не нужен — статический график, обновляется по setter */ update(){} render(){ if (!this.el) return; const W = this.W, H = this.H, pad = this.pad; const wMin = 0, wMax = 2 * this.omega0; /* Подсчитаем все амплитуды чтобы знать max */ function amp(w, g, w0){ const dw2 = (w0 * w0 - w * w); const denom = Math.sqrt(dw2 * dw2 + (2 * g * w) * (2 * g * w)); return 1 / Math.max(denom, 1e-6); } const gMin = 0.05; const ampMax = amp(this.omega0, gMin, this.omega0) * 1.1; /* Сетка */ const left = pad, right = W - pad, top = pad, bot = H - pad; const ux = (right - left) / (wMax - wMin); const uy = (bot - top) / ampMax; function toX(w){ return left + (w - wMin) * ux; } function toY(a){ return bot - a * uy; } let svg = util.svgFrame(W, H); /* Линии сетки */ svg += ''; for (let i = 0; i <= 4; i++){ const w = wMin + (wMax - wMin) * i / 4; svg += ''; } for (let i = 0; i <= 4; i++){ svg += ''; } svg += ''; /* Оси */ svg += ''; svg += ''; /* Подписи осей */ svg += 'ω'; svg += 'A'; /* Линия ω₀ — собственная частота */ svg += ''; svg += 'ω₀'; /* Кривая A(ω) */ let path = 'M '; const N = 200; for (let i = 0; i <= N; i++){ const w = wMin + (wMax - wMin) * i / N; const a = amp(w, this.gamma, this.omega0); path += toX(w).toFixed(1) + ',' + toY(Math.min(a, ampMax)).toFixed(1); if (i < N) path += ' L '; } svg += ''; /* Точка-маркер на текущей ω */ const aCur = Math.min(amp(this.omegaCur, this.gamma, this.omega0), ampMax); svg += ''; svg += ''; /* Подпись γ */ svg += 'γ = ' + this.gamma.toFixed(2) + ''; svg += 'ω = ' + this.omegaCur.toFixed(2) + ' · A = ' + aCur.toFixed(2) + ''; svg += ''; this.el.innerHTML = svg; } } P.ResonanceCurve = ResonanceCurve; /* ============================================================ */ /* TransverseWave — поперечная волна на струне */ /* y(x, t) = A sin(kx - ωt + φ) */ /* ============================================================ */ class TransverseWave { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 560; this.H = opts.height || 180; this.A = opts.A !== undefined ? opts.A : 0.4; /* отн. амплитуда (0..1) */ this.lambda = opts.lambda !== undefined ? opts.lambda : 1.0; /* отн. длина волны */ this.v = opts.v !== undefined ? opts.v : 0.8; /* скорость распространения (отн./с) */ this.color = opts.color || '#0891b2'; this.markerX = opts.markerX !== undefined ? opts.markerX : 0.4; /* пол. красной точки (0..1 от ширины) */ this.paused = false; this.t = 0; util.subscribe(this); util.observe(this); this.render(); } setA(v){ this.A = v; } setLambda(v){ this.lambda = Math.max(0.1, v); } setV(v){ this.v = v; } update(dt){ this.t += dt; } render(){ if (!this.el) return; const W = this.W, H = this.H; const yCenter = H / 2; const amp = this.A * (H / 2 - 18); const k = 2 * Math.PI / this.lambda; const omega = k * this.v; /* SVG: горизонтальная ось + волна как polyline */ let svg = util.svgFrame(W, H, {bg:'#f8fafc'}); /* Ось */ svg += ''; /* Кривая */ const N = 180; let path = 'M '; for (let i = 0; i <= N; i++){ const px = (W * i / N); /* Реальное x в относительных единицах (1 длина волны на ~120px) */ const x = px / 120; const y = yCenter - amp * Math.sin(k * x - omega * this.t); path += px.toFixed(1) + ',' + y.toFixed(1); if (i < N) path += ' L '; } svg += ''; /* Красный маркер — колеблющаяся точка */ const mPx = this.markerX * W; const mX = mPx / 120; const mY = yCenter - amp * Math.sin(k * mX - omega * this.t); svg += ''; svg += ''; /* Метка λ — горизонтальная скобка над волной */ const lambdaPx = 120 * this.lambda; if (lambdaPx < W - 60){ const lxStart = 20, lxEnd = lxStart + lambdaPx; svg += ''; svg += ''; svg += ''; svg += 'λ'; } svg += ''; this.el.innerHTML = svg; } } P.TransverseWave = TransverseWave; /* ============================================================ */ /* LongitudinalWave — продольная волна (сжатия/разрежения) */ /* ============================================================ */ class LongitudinalWave { constructor(container, opts){ opts = opts || {}; this.el = (typeof container === 'string') ? document.querySelector(container) : container; this.W = opts.width || 560; this.H = opts.height || 130; this.A = opts.A !== undefined ? opts.A : 0.5; /* амплитуда (0..1) */ this.lambda = opts.lambda !== undefined ? opts.lambda : 1.0; this.v = opts.v !== undefined ? opts.v : 0.8; this.color = opts.color || '#0891b2'; this.nDots = opts.nDots || 60; this.paused = false; this.t = 0; util.subscribe(this); util.observe(this); this.render(); } setA(v){ this.A = v; } setLambda(v){ this.lambda = Math.max(0.1, v); } setV(v){ this.v = v; } update(dt){ this.t += dt; } render(){ if (!this.el) return; const W = this.W, H = this.H; const yC = H / 2; const k = 2 * Math.PI / this.lambda; const omega = k * this.v; const xScale = 120; /* px на 1 ед. */ const amp = this.A * 10; /* px смещения */ const margin = 20; let svg = util.svgFrame(W, H, {bg:'#f8fafc'}); /* Точки-молекулы */ let dots = ''; for (let i = 0; i < this.nDots; i++){ const x0 = margin + (W - 2 * margin) * i / (this.nDots - 1); const xRel = x0 / xScale; const disp = amp * Math.sin(k * xRel - omega * this.t); const x = x0 + disp; dots += ''; } svg += dots; /* Подписи зон сжатия / разрежения */ svg += 'сжатие ↔ разрежение'; svg += ''; this.el.innerHTML = svg; } } P.LongitudinalWave = LongitudinalWave; })();