22b95ed072
Миграция 031_physics_11_hub.sql: - hub textbook 'physics-11' (cyan, sort 12, para_count 45) - 8 children по главам: ch1 cyan, ch2 violet, ch3 amber, ch4 blue, ch5 pink, ch6 green, ch7 rose, ch8 indigo frontend/js/phys-fx.js (~360 строк): - Глобальный requestAnimationFrame-цикл (Ticker) с подписками - util.subscribe/unsubscribe + IntersectionObserver-пауза невидимых - util.svgFrame, util.axes, util.slider — общие хелперы - PHYS.Oscillogram: гарм. колебания с амплитудой/частотой/фазой/затуханием - PHYS.SpringMass: пружинный маятник (T=2π√(m/k)) с зигзаг-пружиной - PHYS.Pendulum: математический маятник (T=2π√(l/g)) с дугой frontend/textbooks/physics_11_hub.html: - Header cyan-gradient + watermark ФИЗИКА - 4-кол grid карточек глав (8 шт., responsive) - Прогресс-бар курса + API /api/textbooks/physics-11/children frontend/textbooks/physics_11_ch1..ch8.html: - Stub-страницы по образцу geometry_10_r1..r4 (W0) - Список параграфов с ключевыми формулами + 'Будет добавлено в волне WN' - Каждая глава со своей темой (gradient, watermark, цветами) - phys-fx.js подключён сразу (ready для W1+) backend/scripts/gen_phys11_stubs.js — генератор для повторных сборок.
325 lines
15 KiB
JavaScript
325 lines
15 KiB
JavaScript
/* 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;
|
||
|
||
})();
|