Files
Learn_System/frontend/js/phys-fx.js
T
Maxim Dolgolyov f2a1c6e24d feat(phys11 W1): Глава 1 §1-§3 + расширение phys-fx.js (EnergyView)
phys-fx.js (+EnergyView):
- PHYS.EnergyView — график 3 кривых: W_к (красный), W_п (зелёный), W_мех=const (фиолетовый пунктир)
- Использует кинетическую/потенциальную энергию для гарм. колеб.: cos², sin², сумма = 1
- Легенда в правом верхнем углу

physics_11_ch1.html (~63 КБ):
Архитектура geom_10_r1 (geom11-стиль):
- 2-кол layout с col-side (XP card + cheat sheet + tip)
- Hero cyan-градиент + кнопка 'Начать §1'
- psel-grid: 6 параграфов + Финал; §1-§3 активны, §4-§6 и Финал locked
- sec секции с watermark (∿, маятник, E, ☰, ∿, муз. нота, ★)
- card теории + wg workshops + opt-btn кнопки

§1 Колебательное движение. Гарм. колебания:
- 3 теор. карточки (определение, T/ν/ω, гарм. колеб. x=Acos(ωt+φ₀))
- Инт. 1: Oscillogram с ползунками A, ω, φ (live-анимация)
- Инт. 2: Расчёт T,ν,ω (5 задач input)
- Инт. 3: Свойства колеб. (5 MC)
- Босс §1: 5 этапов, +65 XP

§2 Маятники:
- 2 теор. карточки (пружинный T=2π√(m/k), матем. T=2π√(l/g))
- Инт. 1: SpringMass + Pendulum side-by-side с 4 ползунками (m,k,l,g)
- Инт. 2: Расчёт T (5 input)
- Инт. 3: Как изменится T (5 MC)
- Босс §2: 5 этапов, +70 XP

§3 Превращения энергии:
- 2 теор. карточки (формулы W_к, W_п; закон сохранения W_мех=kA²/2)
- Инт. 1: EnergyView с ползунками A, ω (3 кривые в реал. времени)
- Инт. 2: Расчёт энергии (5 input)
- Инт. 3: Превращения энергии (5 MC)
- Босс §3: 5 этапов, +65 XP

§4-§6 и Финал — stub-карточки 'в разработке (W2)'.

LocalStorage: physics11_ch1_*, physics11_xp (общий со всем курсом)
Server sync: /api/textbooks/physics-11-ch1/progress
2026-05-29 17:52:47 +03:00

393 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 '<svg viewBox="0 0 '+w+' '+h+'" preserveAspectRatio="xMidYMid meet" '
+ 'style="width:100%;height:auto;display:block;background:'+bg
+ ';border:'+border+';border-radius:10px">';
},
/* Двухмерные оси 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 = '<g stroke="#e2e8f0" stroke-width="0.8">';
/* Вертикальные линии каждую секунду */
for (let s = 0; s <= tMax; s++) svg += '<line x1="'+toX(s)+'" y1="'+top+'" x2="'+toX(s)+'" y2="'+bot+'"/>';
/* Горизонтальные линии */
const yStep = (yRange[1] - yRange[0]) / 4;
for (let i = 0; i <= 4; i++){
const y = yRange[0] + i * yStep;
svg += '<line x1="'+left+'" y1="'+toY(y)+'" x2="'+right+'" y2="'+toY(y)+'"/>';
}
svg += '</g>';
/* Ось t */
svg += '<line x1="'+left+'" y1="'+toY(0)+'" x2="'+right+'" y2="'+toY(0)+'" stroke="#0f172a" stroke-width="1.4"/>';
/* Ось y */
svg += '<line x1="'+toX(0)+'" y1="'+top+'" x2="'+toX(0)+'" y2="'+bot+'" stroke="#0f172a" stroke-width="1.4"/>';
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 = '<label style="display:flex;align-items:center;gap:8px;font-size:.82rem;color:#475569;font-weight:600;margin:4px 8px">'
+ '<span style="min-width:90px">' + opts.label + '</span>'
+ '<input type="range" id="' + id + '" min="' + opts.min + '" max="' + opts.max + '" step="' + opts.step + '" value="' + opts.value + '" style="flex:1;min-width:80px">'
+ '<b id="' + id + '-v" style="font-family:JetBrains Mono,monospace;color:#0891b2;font-size:.86rem;min-width:50px;text-align:right">' + opts.value + (opts.unit || '') + '</b>'
+ '</label>';
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 = '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + this.color + '" stroke-width="2.4" stroke-linejoin="round"/>';
}
/* Подпись y(t) */
const titleSvg = '<text x="' + (W - pad) + '" y="' + (pad - 8) + '" text-anchor="end" font-size="12" font-family="JetBrains Mono,monospace" fill="' + this.color + '" font-weight="700">' + this.label + '</text>';
const svg = util.svgFrame(W, H) + ax.svg + polyline + titleSvg + '</svg>';
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 = '<g stroke="#cbd5e1" stroke-width="1" font-family="JetBrains Mono,monospace" font-size="10" fill="#64748b">'
+ '<line x1="' + (W - 38) + '" y1="' + (y0 - A_px) + '" x2="' + (W - 38) + '" y2="' + (y0 + A_px) + '" stroke-width="1.6"/>'
+ '<line x1="' + (W - 44) + '" y1="' + (y0 - A_px) + '" x2="' + (W - 32) + '" y2="' + (y0 - A_px) + '"/>'
+ '<text x="' + (W - 26) + '" y="' + (y0 - A_px + 4) + '">+A</text>'
+ '<line x1="' + (W - 44) + '" y1="' + y0 + '" x2="' + (W - 32) + '" y2="' + y0 + '" stroke="#0f172a" stroke-width="1.4"/>'
+ '<text x="' + (W - 26) + '" y="' + (y0 + 4) + '">0</text>'
+ '<line x1="' + (W - 44) + '" y1="' + (y0 + A_px) + '" x2="' + (W - 32) + '" y2="' + (y0 + A_px) + '"/>'
+ '<text x="' + (W - 26) + '" y="' + (y0 + A_px + 4) + '">-A</text>'
+ '</g>';
/* Период справа сверху */
const Tlabel = '<text x="12" y="20" font-family="JetBrains Mono,monospace" font-size="12" fill="' + this.color + '" font-weight="700">T = ' + T.toFixed(2) + ' с</text>';
const svg = util.svgFrame(W, H, {bg:'#f8fafc'})
+ '<line x1="0" y1="' + (hookY - 6) + '" x2="' + W + '" y2="' + (hookY - 6) + '" stroke="#334155" stroke-width="3"/>'
+ '<g stroke="#334155" stroke-width="3" fill="none" stroke-linejoin="round" stroke-linecap="round">'
+ '<path d="' + path + '"/>'
+ '</g>'
+ '<rect x="' + (cx - massR) + '" y="' + (massY - massR) + '" width="' + (2 * massR) + '" height="' + (2 * massR) + '" rx="6" fill="' + this.color + '" stroke="#0f172a" stroke-width="1.6"/>'
+ '<text x="' + cx + '" y="' + (massY + 5) + '" text-anchor="middle" font-family="Outfit,sans-serif" font-size="14" font-weight="800" fill="#fff">m</text>'
+ ruler + Tlabel
+ '</svg>';
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 = '<path d="M ' + aS.x.toFixed(1) + ' ' + aS.y.toFixed(1) + ' A ' + arcR + ' ' + arcR + ' 0 ' + largeArc + ' ' + sweep + ' ' + aE.x.toFixed(1) + ' ' + aE.y.toFixed(1) + '" fill="none" stroke="#cbd5e1" stroke-width="1.4" stroke-dasharray="4 4"/>';
/* Вертикальная пунктирная ось */
const vert = '<line x1="' + cx + '" y1="' + hookY + '" x2="' + cx + '" y2="' + (hookY + Lpx + 5) + '" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="3 3"/>';
/* Подвес */
const string = '<line x1="' + cx + '" y1="' + hookY + '" x2="' + bx.toFixed(1) + '" y2="' + by.toFixed(1) + '" stroke="#0f172a" stroke-width="2"/>';
const bob = '<circle cx="' + bx.toFixed(1) + '" cy="' + by.toFixed(1) + '" r="' + bobR + '" fill="' + this.color + '" stroke="#0f172a" stroke-width="1.6"/>';
/* Период */
const Tlabel = '<text x="12" y="20" font-family="JetBrains Mono,monospace" font-size="12" fill="' + this.color + '" font-weight="700">T = ' + T.toFixed(2) + ' с</text>';
/* Подвес-крепление */
const hook = '<line x1="' + (cx - 30) + '" y1="' + (hookY - 6) + '" x2="' + (cx + 30) + '" y2="' + (hookY - 6) + '" stroke="#334155" stroke-width="3"/>';
const svg = util.svgFrame(W, H, {bg:'#f8fafc'}) + hook + vert + arc + string + bob + Tlabel + '</svg>';
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 '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + color + '" stroke-width="2.2" stroke-linejoin="round"/>';
}
const pK = path.call(this, 1, '#dc2626');
const pP = path.call(this, 2, '#16a34a');
/* W_мех = const = 1 (горизонтальная линия) */
const pM = '<line x1="' + ax.left + '" y1="' + ax.toY(1) + '" x2="' + ax.right + '" y2="' + ax.toY(1) + '" stroke="#7c3aed" stroke-width="2.4" stroke-dasharray="6 4"/>';
/* Легенда */
const legend = '<g font-family="JetBrains Mono,monospace" font-size="11" font-weight="700">'
+ '<rect x="' + (W - 130) + '" y="' + (pad - 28) + '" width="118" height="74" fill="#fff" stroke="#e2e8f0" rx="6"/>'
+ '<line x1="' + (W - 122) + '" y1="' + (pad - 14) + '" x2="' + (W - 102) + '" y2="' + (pad - 14) + '" stroke="#dc2626" stroke-width="2.4"/>'
+ '<text x="' + (W - 96) + '" y="' + (pad - 10) + '" fill="#dc2626">W кинет.</text>'
+ '<line x1="' + (W - 122) + '" y1="' + (pad + 6) + '" x2="' + (W - 102) + '" y2="' + (pad + 6) + '" stroke="#16a34a" stroke-width="2.4"/>'
+ '<text x="' + (W - 96) + '" y="' + (pad + 10) + '" fill="#16a34a">W потенц.</text>'
+ '<line x1="' + (W - 122) + '" y1="' + (pad + 26) + '" x2="' + (W - 102) + '" y2="' + (pad + 26) + '" stroke="#7c3aed" stroke-width="2.4" stroke-dasharray="4 3"/>'
+ '<text x="' + (W - 96) + '" y="' + (pad + 30) + '" fill="#7c3aed">W мех = const</text>'
+ '</g>';
const svg = util.svgFrame(W, H) + ax.svg + pM + pK + pP + legend + '</svg>';
this.el.innerHTML = svg;
}
}
P.EnergyView = EnergyView;
})();