Files
Learn_System/frontend/js/phys-fx.js
T
Maxim Dolgolyov 27a67d0866 feat(phys11 ch3): Wave 6 — §18-§20 + phys-fx SphericalMirror/RefractionLab/PrismSpectrum
- phys-fx.js: PHYS.SphericalMirror (вогнутые/выпуклые с формулой и 3 лучами), PHYS.RefractionLab (закон Снелла + ПВО), PHYS.PrismSpectrum (дисперсия, 7 цветов через модель Коши)
- §18: сферические зеркала, формула 1/d + 1/f = 1/F, увеличение Γ = -f/d, построение
- §19: показатель преломления n=c/v, закон Снелла, полное внутр. отражение
- §20: призма + дисперсия, плоскопараллельная пластинка, оптоволокно
- 6 квизов (I5-I7 CALC/TH) + 3 босса (b5-b7)
- §21-§23 + Final остаются заглушками для W7
2026-05-29 18:41:51 +03:00

1333 lines
76 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;
/* ============================================================ */
/* 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 += '<g stroke="#e2e8f0" stroke-width="0.8">';
for (let i = 0; i <= 4; i++){
const w = wMin + (wMax - wMin) * i / 4;
svg += '<line x1="' + toX(w) + '" y1="' + top + '" x2="' + toX(w) + '" y2="' + bot + '"/>';
}
for (let i = 0; i <= 4; i++){
svg += '<line x1="' + left + '" y1="' + (top + (bot - top) * i / 4) + '" x2="' + right + '" y2="' + (top + (bot - top) * i / 4) + '"/>';
}
svg += '</g>';
/* Оси */
svg += '<line x1="' + left + '" y1="' + bot + '" x2="' + right + '" y2="' + bot + '" stroke="#0f172a" stroke-width="1.4"/>';
svg += '<line x1="' + left + '" y1="' + top + '" x2="' + left + '" y2="' + bot + '" stroke="#0f172a" stroke-width="1.4"/>';
/* Подписи осей */
svg += '<text x="' + (right - 4) + '" y="' + (bot - 6) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#64748b" text-anchor="end">ω</text>';
svg += '<text x="' + (left + 4) + '" y="' + (top + 12) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#64748b">A</text>';
/* Линия ω₀ — собственная частота */
svg += '<line x1="' + toX(this.omega0) + '" y1="' + top + '" x2="' + toX(this.omega0) + '" y2="' + bot + '" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="4 4"/>';
svg += '<text x="' + toX(this.omega0) + '" y="' + (top - 4) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#64748b" text-anchor="middle">ω₀</text>';
/* Кривая 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 += '<path d="' + path + '" fill="none" stroke="' + this.color + '" stroke-width="2.6" stroke-linejoin="round"/>';
/* Точка-маркер на текущей ω */
const aCur = Math.min(amp(this.omegaCur, this.gamma, this.omega0), ampMax);
svg += '<line x1="' + toX(this.omegaCur) + '" y1="' + toY(0) + '" x2="' + toX(this.omegaCur) + '" y2="' + toY(aCur) + '" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="3 3"/>';
svg += '<circle cx="' + toX(this.omegaCur) + '" cy="' + toY(aCur) + '" r="6" fill="#dc2626" stroke="#fff" stroke-width="2"/>';
/* Подпись γ */
svg += '<text x="' + (left + 10) + '" y="' + (top + 16) + '" font-family="JetBrains Mono,monospace" font-size="12" fill="' + this.color + '" font-weight="700">γ = ' + this.gamma.toFixed(2) + '</text>';
svg += '<text x="' + (left + 10) + '" y="' + (top + 32) + '" font-family="JetBrains Mono,monospace" font-size="12" fill="#dc2626" font-weight="700">ω = ' + this.omegaCur.toFixed(2) + ' · A = ' + aCur.toFixed(2) + '</text>';
svg += '</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 += '<line x1="0" y1="' + yCenter + '" x2="' + W + '" y2="' + yCenter + '" stroke="#cbd5e1" stroke-width="1" stroke-dasharray="4 3"/>';
/* Кривая */
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 += '<path d="' + path + '" fill="none" stroke="' + this.color + '" stroke-width="2.6" stroke-linejoin="round"/>';
/* Красный маркер — колеблющаяся точка */
const mPx = this.markerX * W;
const mX = mPx / 120;
const mY = yCenter - amp * Math.sin(k * mX - omega * this.t);
svg += '<line x1="' + mPx + '" y1="' + (yCenter - amp) + '" x2="' + mPx + '" y2="' + (yCenter + amp) + '" stroke="#fca5a5" stroke-width="1" stroke-dasharray="3 3"/>';
svg += '<circle cx="' + mPx + '" cy="' + mY.toFixed(1) + '" r="7" fill="#dc2626" stroke="#fff" stroke-width="2"/>';
/* Метка λ — горизонтальная скобка над волной */
const lambdaPx = 120 * this.lambda;
if (lambdaPx < W - 60){
const lxStart = 20, lxEnd = lxStart + lambdaPx;
svg += '<line x1="' + lxStart + '" y1="20" x2="' + lxEnd + '" y2="20" stroke="#7c3aed" stroke-width="1.6"/>';
svg += '<line x1="' + lxStart + '" y1="14" x2="' + lxStart + '" y2="26" stroke="#7c3aed" stroke-width="1.6"/>';
svg += '<line x1="' + lxEnd + '" y1="14" x2="' + lxEnd + '" y2="26" stroke="#7c3aed" stroke-width="1.6"/>';
svg += '<text x="' + ((lxStart + lxEnd) / 2) + '" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" font-weight="700" fill="#7c3aed">λ</text>';
}
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 += '<circle cx="' + x.toFixed(1) + '" cy="' + yC + '" r="3" fill="' + this.color + '"/>';
}
svg += dots;
/* Подписи зон сжатия / разрежения */
svg += '<text x="' + (W / 2) + '" y="' + (H - 6) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#94a3b8">сжатие &harr; разрежение</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.LongitudinalWave = LongitudinalWave;
/* ============================================================ */
/* LCcircuit — колебательный контур */
/* q(t) = Q0 cos(ωt), i(t) = -Q0 ω sin(ωt), ω = 1/√(LC) */
/* ============================================================ */
class LCcircuit {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 480;
this.H = opts.height || 280;
this.L = opts.L !== undefined ? opts.L : 0.01; /* Гн */
this.C = opts.C !== undefined ? opts.C : 1e-6; /* Ф */
this.Q0 = opts.Q0 !== undefined ? opts.Q0 : 1.0; /* нормированный заряд */
this.color = opts.color || '#7c3aed';
this.paused = false;
this.t = 0;
util.subscribe(this);
util.observe(this);
this.render();
}
setL(L){ this.L = Math.max(1e-4, L); }
setC(C){ this.C = Math.max(1e-9, C); }
period(){ return 2 * Math.PI * Math.sqrt(this.L * this.C); }
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 phase = omega * this.t;
const q = this.Q0 * Math.cos(phase);
const i = -this.Q0 * omega * Math.sin(phase);
/* Геометрия: C слева вверху, L справа вверху, соединены проводами */
const cx = W / 2, cy = H / 2 - 20;
const cap = {x: cx - 100, y: cy};
const ind = {x: cx + 100, y: cy};
/* Energies (для подсветки): W_C ~ q², W_L ~ i² */
const WC = q * q;
const WL = (i / omega) * (i / omega); /* в норм. единицах */
const total = WC + WL;
const cOpacity = 0.3 + 0.7 * WC / total;
const lOpacity = 0.3 + 0.7 * WL / total;
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Провода */
const wireY1 = cy - 50, wireY2 = cy + 50;
svg += '<path d="M ' + cap.x + ' ' + (cy - 18) + ' L ' + cap.x + ' ' + wireY1 + ' L ' + ind.x + ' ' + wireY1 + ' L ' + ind.x + ' ' + (cy - 18) + '" fill="none" stroke="#0f172a" stroke-width="2"/>';
svg += '<path d="M ' + cap.x + ' ' + (cy + 18) + ' L ' + cap.x + ' ' + wireY2 + ' L ' + ind.x + ' ' + wireY2 + ' L ' + ind.x + ' ' + (cy + 18) + '" fill="none" stroke="#0f172a" stroke-width="2"/>';
/* Конденсатор: две параллельные пластины */
const plateW = 36;
svg += '<g stroke="#0f172a" stroke-width="3">';
svg += '<line x1="' + (cap.x - plateW/2) + '" y1="' + (cap.y - 18) + '" x2="' + (cap.x + plateW/2) + '" y2="' + (cap.y - 18) + '"/>';
svg += '<line x1="' + (cap.x - plateW/2) + '" y1="' + (cap.y + 18) + '" x2="' + (cap.x + plateW/2) + '" y2="' + (cap.y + 18) + '"/>';
svg += '</g>';
/* Заряды на пластинах */
const sign = q > 0 ? 1 : -1;
const topCh = sign > 0 ? '+' : '';
const botCh = sign > 0 ? '' : '+';
const qAbsNorm = Math.abs(q) / this.Q0;
svg += '<text x="' + cap.x + '" y="' + (cap.y - 24) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="14" font-weight="800" fill="#dc2626" opacity="' + (qAbsNorm).toFixed(2) + '">' + topCh + '</text>';
svg += '<text x="' + cap.x + '" y="' + (cap.y + 36) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="14" font-weight="800" fill="#2563eb" opacity="' + (qAbsNorm).toFixed(2) + '">' + botCh + '</text>';
svg += '<text x="' + (cap.x - 30) + '" y="' + (cap.y + 5) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="13" fill="#dc2626" font-weight="700">C</text>';
/* Индуктор: петли */
const coils = 4, coilR = 8, coilW = 64;
let coilPath = 'M ' + (ind.x - coilW/2) + ' ' + ind.y;
for (let k = 0; k < coils; k++){
const x0 = ind.x - coilW/2 + (coilW / coils) * k;
coilPath += ' a ' + coilR + ' ' + coilR + ' 0 0 1 ' + (coilW / coils) + ' 0';
}
svg += '<path d="' + coilPath + '" fill="none" stroke="#0f172a" stroke-width="2.4"/>';
svg += '<text x="' + (ind.x + 40) + '" y="' + (ind.y + 5) + '" font-family="JetBrains Mono,monospace" font-size="13" fill="#2563eb" font-weight="700">L</text>';
/* Стрелка тока */
const iDir = i > 0 ? 1 : -1;
const iAbs = Math.abs(i) / (this.Q0 * omega);
if (iAbs > 0.05){
const aY = wireY1 - 14;
const aX1 = cx - 30 * iDir;
const aX2 = cx + 30 * iDir;
svg += '<line x1="' + aX1 + '" y1="' + aY + '" x2="' + aX2 + '" y2="' + aY + '" stroke="#16a34a" stroke-width="2.4" opacity="' + iAbs.toFixed(2) + '"/>';
svg += '<polygon points="' + aX2 + ',' + aY + ' ' + (aX2 - 8 * iDir) + ',' + (aY - 4) + ' ' + (aX2 - 8 * iDir) + ',' + (aY + 4) + '" fill="#16a34a" opacity="' + iAbs.toFixed(2) + '"/>';
svg += '<text x="' + cx + '" y="' + (aY - 6) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="12" fill="#16a34a" font-weight="700">i</text>';
}
/* Энергетические столбцы */
const eY = H - 36, eH = 24;
svg += '<rect x="40" y="' + eY + '" width="120" height="' + eH + '" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>';
svg += '<rect x="40" y="' + eY + '" width="' + (120 * WC / total).toFixed(1) + '" height="' + eH + '" fill="#dc2626" opacity="0.7"/>';
svg += '<text x="' + (40 + 60) + '" y="' + (eY + eH + 12) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">W_C</text>';
svg += '<rect x="' + (W - 160) + '" y="' + eY + '" width="120" height="' + eH + '" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>';
svg += '<rect x="' + (W - 160) + '" y="' + eY + '" width="' + (120 * WL / total).toFixed(1) + '" height="' + eH + '" fill="#2563eb" opacity="0.7"/>';
svg += '<text x="' + (W - 100) + '" y="' + (eY + eH + 12) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">W_L</text>';
/* Подпись периода */
const Tdisp = T < 1e-3 ? (T * 1e6).toFixed(1) + ' мкс' : (T * 1e3).toFixed(2) + ' мс';
svg += '<text x="' + (W / 2) + '" y="22" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" fill="' + this.color + '" font-weight="800">T = 2π√(LC) = ' + Tdisp + '</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.LCcircuit = LCcircuit;
/* ============================================================ */
/* ACgen — генератор переменного тока (вращающаяся рамка в B) */
/* ============================================================ */
class ACgen {
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.omega = opts.omega !== undefined ? opts.omega : 2 * Math.PI; /* рад/с */
this.U0 = opts.U0 !== undefined ? opts.U0 : 1.0;
this.color = opts.color || '#7c3aed';
this.tWindow = opts.tWindow || 4;
this.paused = false;
this.t = 0;
this.history = [];
util.subscribe(this);
util.observe(this);
this.render();
}
setOmega(w){ this.omega = w; this.history = []; }
update(dt){
this.t += dt;
this.history.push([this.t, this.U0 * Math.sin(this.omega * this.t)]);
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;
/* Левая часть: рамка в магнитном поле; правая: график U(t) */
const leftW = 200;
const rightLeft = leftW + 10;
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Магнитное поле (стрелки B) */
svg += '<g stroke="#94a3b8" stroke-width="1.2">';
for (let i = 0; i < 5; i++){
const x = 20 + i * 40;
svg += '<line x1="' + x + '" y1="20" x2="' + x + '" y2="' + (H - 20) + '" marker-end="url(#acgen-arr)"/>';
}
svg += '</g>';
svg += '<defs><marker id="acgen-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8"/></marker></defs>';
svg += '<text x="14" y="14" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">B</text>';
/* Вращающаяся рамка: эллипс, отображающий проекцию прямоугольника */
const fx = 110, fy = H / 2;
const phi = this.omega * this.t;
const rx = 40 * Math.abs(Math.cos(phi));
const ry = 30;
svg += '<ellipse cx="' + fx + '" cy="' + fy + '" rx="' + rx.toFixed(1) + '" ry="' + ry + '" fill="none" stroke="#dc2626" stroke-width="2.4"/>';
/* Ось вращения */
svg += '<line x1="' + fx + '" y1="' + (fy - 50) + '" x2="' + fx + '" y2="' + (fy + 50) + '" stroke="#0f172a" stroke-width="2" stroke-dasharray="4 3"/>';
/* Контакт скользящих колец (схема) */
svg += '<line x1="' + fx + '" y1="' + (fy + 50) + '" x2="' + fx + '" y2="' + (fy + 70) + '" stroke="#0f172a" stroke-width="2"/>';
svg += '<line x1="' + fx + '" y1="' + (fy + 70) + '" x2="' + rightLeft + '" y2="' + (fy + 70) + '" stroke="#0f172a" stroke-width="2"/>';
/* График U(t) — справа */
const gPad = 26;
const tMin = Math.max(0, this.t - this.tWindow);
const ax = util.axes(W - rightLeft, H, gPad, this.tWindow, [-this.U0 * 1.2, this.U0 * 1.2]);
svg += '<g transform="translate(' + rightLeft + ' 0)">' + ax.svg + '</g>';
if (this.history.length > 1){
const pts = this.history.map(([t, y]) => (rightLeft + ax.left + (t - tMin) * (ax.right - ax.left) / this.tWindow).toFixed(1) + ',' + ax.toY(y).toFixed(1));
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + this.color + '" stroke-width="2.4"/>';
}
/* Подпись */
svg += '<text x="' + (rightLeft + 20) + '" y="20" font-family="JetBrains Mono,monospace" font-size="12" fill="' + this.color + '" font-weight="700">U(t) = U₀ sin(ωt)</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.ACgen = ACgen;
/* ============================================================ */
/* Transformer — схема трансформатора с расчётом */
/* ============================================================ */
class Transformer {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 520;
this.H = opts.height || 240;
this.N1 = opts.N1 || 200;
this.N2 = opts.N2 || 50;
this.U1 = opts.U1 || 220;
this.color = opts.color || '#7c3aed';
this.render();
}
setN1(n){ this.N1 = Math.max(1, n|0); this.render(); }
setN2(n){ this.N2 = Math.max(1, n|0); this.render(); }
setU1(u){ this.U1 = u; this.render(); }
update(){ /* статика */ }
render(){
if (!this.el) return;
const W = this.W, H = this.H;
const k = this.N1 / this.N2;
const U2 = this.U1 / k;
const cy = H / 2;
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Сердечник трансформатора (прямоугольник с вырезом) */
const coreL = 110, coreR = W - 110;
svg += '<rect x="' + coreL + '" y="' + (cy - 70) + '" width="' + (coreR - coreL) + '" height="140" fill="none" stroke="#64748b" stroke-width="6"/>';
svg += '<rect x="' + (coreL + 16) + '" y="' + (cy - 54) + '" width="' + (coreR - coreL - 32) + '" height="108" fill="#f8fafc" stroke="none"/>';
/* Первичная обмотка слева */
const coil1X = coreL + 24;
for (let i = 0; i < 6; i++){
const y = cy - 50 + i * 16;
svg += '<path d="M ' + (coil1X - 12) + ' ' + y + ' a 8 8 0 0 1 8 -8 a 8 8 0 0 1 8 8 a 8 8 0 0 1 -8 8 a 8 8 0 0 1 -8 -8 z" fill="none" stroke="#dc2626" stroke-width="2.4"/>';
}
/* Вторичная обмотка справа */
const coil2X = coreR - 24;
/* Адаптируем число витков визуально (max 8 для удобства) */
const visTurns2 = Math.max(2, Math.min(10, Math.round(6 * this.N2 / this.N1)));
for (let i = 0; i < visTurns2; i++){
const y = cy - 50 + i * (100 / visTurns2);
svg += '<path d="M ' + (coil2X - 12) + ' ' + y + ' a 6 6 0 0 1 8 -6 a 6 6 0 0 1 8 6 a 6 6 0 0 1 -8 6 a 6 6 0 0 1 -8 -6 z" fill="none" stroke="#2563eb" stroke-width="2.4"/>';
}
/* Провода-выходы первичной */
svg += '<line x1="' + (coil1X - 12) + '" y1="' + (cy - 50) + '" x2="20" y2="' + (cy - 50) + '" stroke="#dc2626" stroke-width="2"/>';
svg += '<line x1="' + (coil1X - 12) + '" y1="' + (cy + 50) + '" x2="20" y2="' + (cy + 50) + '" stroke="#dc2626" stroke-width="2"/>';
/* Провода вторичной */
svg += '<line x1="' + (coil2X + 4) + '" y1="' + (cy - 50) + '" x2="' + (W - 20) + '" y2="' + (cy - 50) + '" stroke="#2563eb" stroke-width="2"/>';
svg += '<line x1="' + (coil2X + 4) + '" y1="' + (cy + 50) + '" x2="' + (W - 20) + '" y2="' + (cy + 50) + '" stroke="#2563eb" stroke-width="2"/>';
/* Подписи N1, N2, U1, U2 */
svg += '<text x="' + (coil1X - 16) + '" y="' + (cy - 60) + '" font-family="JetBrains Mono,monospace" font-size="13" fill="#dc2626" font-weight="800">N₁ = ' + this.N1 + '</text>';
svg += '<text x="' + (coil2X + 16) + '" y="' + (cy - 60) + '" font-family="JetBrains Mono,monospace" font-size="13" fill="#2563eb" font-weight="800" text-anchor="end">N₂ = ' + this.N2 + '</text>';
svg += '<text x="' + (coil2X + 6) + '" y="' + (cy - 60) + '" font-family="JetBrains Mono,monospace" font-size="13" fill="#2563eb" font-weight="800">N₂ = ' + this.N2 + '</text>';
svg += '<text x="20" y="' + (cy - 56) + '" font-family="JetBrains Mono,monospace" font-size="12" fill="#dc2626">U₁ = ' + this.U1.toFixed(0) + ' В</text>';
svg += '<text x="' + (W - 20) + '" y="' + (cy - 56) + '" font-family="JetBrains Mono,monospace" font-size="12" fill="#2563eb" text-anchor="end">U₂ = ' + U2.toFixed(1) + ' В</text>';
/* Подпись коэф. */
svg += '<text x="' + (W/2) + '" y="22" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" fill="' + this.color + '" font-weight="800">k = N₁/N₂ = ' + k.toFixed(2) + '</text>';
svg += '<text x="' + (W/2) + '" y="' + (H - 12) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#64748b">' + (k > 1 ? 'понижающий' : 'повышающий') + '</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.Transformer = Transformer;
/* ============================================================ */
/* TwoSlit — интерференция от двух щелей (опыт Юнга) */
/* ============================================================ */
class TwoSlit {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 540;
this.H = opts.height || 200;
this.d = opts.d !== undefined ? opts.d : 0.5; /* расстояние между щелями (отн. ед.) */
this.L = opts.L !== undefined ? opts.L : 5; /* расстояние до экрана */
this.lambda = opts.lambda !== undefined ? opts.lambda : 0.05; /* длина волны */
this.color = opts.color || '#f59e0b';
this.paused = true; /* статика */
this.render();
}
setD(v){ this.d = Math.max(0.05, v); this.render(); }
setLambda(v){ this.lambda = Math.max(0.005, v); this.render(); }
update(){}
render(){
if (!this.el) return;
const W = this.W, H = this.H;
/* Слева: щели (две точки), справа: экран с полосами */
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Лазер слева */
svg += '<rect x="6" y="' + (H/2 - 14) + '" width="40" height="28" fill="#fca5a5" stroke="#0f172a" stroke-width="1.4" rx="3"/>';
svg += '<text x="26" y="' + (H/2 + 4) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" font-weight="700">laser</text>';
/* Щели */
const slitX = 130;
const dPx = Math.min(60, this.d * 60);
const s1y = H/2 - dPx/2, s2y = H/2 + dPx/2;
svg += '<line x1="' + slitX + '" y1="20" x2="' + slitX + '" y2="' + (s1y - 4) + '" stroke="#0f172a" stroke-width="3"/>';
svg += '<line x1="' + slitX + '" y1="' + (s1y + 4) + '" x2="' + slitX + '" y2="' + (s2y - 4) + '" stroke="#0f172a" stroke-width="3"/>';
svg += '<line x1="' + slitX + '" y1="' + (s2y + 4) + '" x2="' + slitX + '" y2="' + (H - 20) + '" stroke="#0f172a" stroke-width="3"/>';
/* Лучи от лазера к щелям */
svg += '<line x1="46" y1="' + (H/2) + '" x2="' + slitX + '" y2="' + s1y + '" stroke="#fca5a5" stroke-width="1.4" opacity="0.6"/>';
svg += '<line x1="46" y1="' + (H/2) + '" x2="' + slitX + '" y2="' + s2y + '" stroke="#fca5a5" stroke-width="1.4" opacity="0.6"/>';
/* Экран */
const screenX = W - 50;
svg += '<line x1="' + screenX + '" y1="20" x2="' + screenX + '" y2="' + (H - 20) + '" stroke="#0f172a" stroke-width="3"/>';
/* Интерференционная картина: интенсивность ~ cos²(πdy/(λL)) */
const lp = this.lambda * this.L / this.d; /* шаг полос */
const lpPx = Math.max(3, Math.min(80, lp * 200));
const halfH = (H - 40) / 2;
const N = 240;
for (let i = 0; i < N; i++){
const y = 20 + (H - 40) * i / N;
const ry = y - H/2;
const intens = Math.pow(Math.cos(Math.PI * ry / lpPx), 2);
const op = intens.toFixed(3);
svg += '<rect x="' + screenX + '" y="' + y.toFixed(1) + '" width="22" height="' + ((H - 40) / N + 1).toFixed(1) + '" fill="' + this.color + '" opacity="' + op + '"/>';
}
/* Подпись формулы */
svg += '<text x="' + (W/2) + '" y="' + (H - 4) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569">d·sin φ = k·λ (max), d=' + this.d.toFixed(2) + ', λ=' + this.lambda.toFixed(3) + '</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.TwoSlit = TwoSlit;
/* ============================================================ */
/* DiffractionGrating — дифракционная решётка + спектр */
/* ============================================================ */
class DiffractionGrating {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 540;
this.H = opts.height || 220;
this.d = opts.d !== undefined ? opts.d : 2e-6; /* период решётки, м */
this.lambda = opts.lambda !== undefined ? opts.lambda : 550e-9; /* λ, м */
this.color = opts.color || '#22c55e';
this.paused = true;
this.render();
}
setD(v){ this.d = Math.max(0.3e-6, v); this.render(); }
setLambda(v){ this.lambda = Math.max(380e-9, Math.min(760e-9, v)); this.render(); }
update(){}
/* Возвращает цвет HEX для данной длины волны (для видимого света) */
wavelengthToColor(lamNm){
const ranges = [
[380, 440, '#7c3aed'], [440, 490, '#3b82f6'], [490, 520, '#06b6d4'],
[520, 570, '#22c55e'], [570, 590, '#facc15'], [590, 630, '#f97316'],
[630, 760, '#dc2626']
];
for (const [lo, hi, c] of ranges) if (lamNm >= lo && lamNm < hi) return c;
return '#94a3b8';
}
render(){
if (!this.el) return;
const W = this.W, H = this.H;
let svg = util.svgFrame(W, H, {bg:'#0f172a'}); /* тёмный фон для спектра */
/* Решётка слева */
const gx = 50;
svg += '<rect x="' + (gx - 6) + '" y="40" width="12" height="' + (H - 80) + '" fill="#475569"/>';
/* «штрихи» решётки */
for (let i = 0; i < 10; i++){
const y = 40 + i * (H - 80) / 10;
svg += '<line x1="' + gx + '" y1="' + y + '" x2="' + (gx + 6) + '" y2="' + y + '" stroke="#fff" stroke-width="0.8"/>';
}
svg += '<text x="' + gx + '" y="30" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#fff">решётка</text>';
/* Падающий луч */
svg += '<line x1="10" y1="' + (H/2) + '" x2="' + (gx - 6) + '" y2="' + (H/2) + '" stroke="' + this.wavelengthToColor(this.lambda * 1e9) + '" stroke-width="2.2"/>';
/* Линии порядков k = -3..3 */
const cx = gx + 6, cy = H/2;
const lamNm = this.lambda * 1e9;
const color = this.wavelengthToColor(lamNm);
for (let k = -3; k <= 3; k++){
const sinPhi = k * this.lambda / this.d;
if (Math.abs(sinPhi) > 1) continue;
const phi = Math.asin(sinPhi);
const dx = 400, dy = dx * Math.tan(phi);
const x2 = cx + dx, y2 = cy - dy;
const op = k === 0 ? 1.0 : (1 - Math.abs(k) * 0.18);
svg += '<line x1="' + cx + '" y1="' + cy + '" x2="' + x2 + '" y2="' + y2.toFixed(1) + '" stroke="' + color + '" stroke-width="' + (2.4 - Math.abs(k) * 0.3) + '" opacity="' + op.toFixed(2) + '"/>';
svg += '<text x="' + (x2 - 24) + '" y="' + (y2 - 6).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="' + color + '" font-weight="700">k=' + k + '</text>';
}
/* Подпись формулы и параметров */
svg += '<text x="' + (W/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#cbd5e1">d sin φ = kλ · d=' + (this.d * 1e6).toFixed(2) + ' мкм · λ=' + (this.lambda * 1e9).toFixed(0) + ' нм</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.DiffractionGrating = DiffractionGrating;
/* ============================================================ */
/* FlatMirror — плоское зеркало, объект и мнимое изображение */
/* ============================================================ */
class FlatMirror {
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.objX = opts.objX !== undefined ? opts.objX : 100; /* px от зеркала */
this.objY = opts.objY !== undefined ? opts.objY : 50;
this.objH = opts.objH !== undefined ? opts.objH : 50;
this.color = opts.color || '#f59e0b';
this.paused = true;
this.render();
}
setObjX(v){ this.objX = Math.max(20, v); this.render(); }
setObjY(v){ this.objY = v; this.render(); }
update(){}
render(){
if (!this.el) return;
const W = this.W, H = this.H;
const cy = H / 2 + 30;
const mirrorX = W / 2;
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Зеркало с штриховкой */
svg += '<line x1="' + mirrorX + '" y1="30" x2="' + mirrorX + '" y2="' + (H - 30) + '" stroke="#0f172a" stroke-width="3"/>';
for (let i = 0; i < 12; i++){
const y = 30 + i * (H - 60) / 12;
svg += '<line x1="' + (mirrorX + 1) + '" y1="' + y + '" x2="' + (mirrorX + 10) + '" y2="' + (y - 8) + '" stroke="#0f172a" stroke-width="1.2"/>';
}
svg += '<text x="' + (mirrorX + 14) + '" y="' + (H - 16) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a">зеркало</text>';
/* Объект — стрелка */
const objX = mirrorX - this.objX;
const objBaseY = cy;
const objTopY = cy - this.objH;
svg += '<line x1="' + objX + '" y1="' + objBaseY + '" x2="' + objX + '" y2="' + objTopY + '" stroke="' + this.color + '" stroke-width="3"/>';
svg += '<polygon points="' + objX + ',' + objTopY + ' ' + (objX - 6) + ',' + (objTopY + 10) + ' ' + (objX + 6) + ',' + (objTopY + 10) + '" fill="' + this.color + '"/>';
svg += '<text x="' + (objX - 5) + '" y="' + (objBaseY + 16) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="' + this.color + '" font-weight="700">объект</text>';
/* Мнимое изображение справа от зеркала, симметрично */
const imgX = mirrorX + this.objX;
svg += '<line x1="' + imgX + '" y1="' + objBaseY + '" x2="' + imgX + '" y2="' + objTopY + '" stroke="' + this.color + '" stroke-width="2" stroke-dasharray="4 3" opacity="0.65"/>';
svg += '<polygon points="' + imgX + ',' + objTopY + ' ' + (imgX - 6) + ',' + (objTopY + 10) + ' ' + (imgX + 6) + ',' + (objTopY + 10) + '" fill="' + this.color + '" opacity="0.5"/>';
svg += '<text x="' + (imgX + 5) + '" y="' + (objBaseY + 16) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="' + this.color + '" opacity="0.7">изображение</text>';
/* Лучи: 1) от верха объекта горизонтально на зеркало, отражается под тем же углом
2) от верха объекта в зеркало по диагонали, отражается симметрично */
/* Луч 1 */
svg += '<line x1="' + objX + '" y1="' + objTopY + '" x2="' + mirrorX + '" y2="' + objTopY + '" stroke="#dc2626" stroke-width="1.6"/>';
svg += '<line x1="' + mirrorX + '" y1="' + objTopY + '" x2="' + objX + '" y2="' + objTopY + '" stroke="#dc2626" stroke-width="1.6"/>';
/* Продолжение в зазеркалье (пунктир) */
svg += '<line x1="' + mirrorX + '" y1="' + objTopY + '" x2="' + imgX + '" y2="' + objTopY + '" stroke="#dc2626" stroke-width="1" stroke-dasharray="3 3"/>';
/* Луч 2: от верха к точке наблюдения слева внизу */
const eyeX = 40, eyeY = H - 50;
const hitY = objTopY + (cy - objTopY) * (mirrorX - objX) / (eyeX - objX + 2 * (mirrorX - objX));
/* Упрощённо: луч от верха объекта к зеркалу и затем к глазу */
const hitX = mirrorX;
const hitYsimple = objTopY + (cy + 30 - objTopY) * 0.3;
svg += '<line x1="' + objX + '" y1="' + objTopY + '" x2="' + hitX + '" y2="' + hitYsimple.toFixed(1) + '" stroke="#16a34a" stroke-width="1.6"/>';
svg += '<line x1="' + hitX + '" y1="' + hitYsimple.toFixed(1) + '" x2="' + eyeX + '" y2="' + eyeY + '" stroke="#16a34a" stroke-width="1.6"/>';
/* Продолжение от точки отражения в зазеркалье */
svg += '<line x1="' + hitX + '" y1="' + hitYsimple.toFixed(1) + '" x2="' + imgX + '" y2="' + objTopY + '" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>';
/* Глаз */
svg += '<circle cx="' + eyeX + '" cy="' + eyeY + '" r="10" fill="#fff" stroke="#0f172a" stroke-width="1.4"/>';
svg += '<circle cx="' + eyeX + '" cy="' + eyeY + '" r="4" fill="#0f172a"/>';
svg += '<text x="' + eyeX + '" y="' + (eyeY + 20) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">наблюдатель</text>';
/* Подпись закона */
svg += '<text x="' + (W/2) + '" y="22" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">∠ пад = ∠ отр · изображение мнимое, прямое, равное</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.FlatMirror = FlatMirror;
/* ============================================================ */
/* SphericalMirror — вогнутое / выпуклое зеркало */
/* ============================================================ */
class SphericalMirror {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 620;
this.H = opts.height || 280;
this.F = opts.F !== undefined ? opts.F : 80; /* фокусное расстояние в px */
this.d = opts.d !== undefined ? opts.d : 180; /* расстояние до объекта в px */
this.objH = opts.objH !== undefined ? opts.objH : 50; /* высота объекта в px */
this.mode = opts.mode || 'concave'; /* 'concave' | 'convex' */
this.color = opts.color || '#d97706';
this.paused = true;
this.render();
}
setF(v){ this.F = Math.max(20, v); this.render(); }
setD(v){ this.d = Math.max(20, v); this.render(); }
setMode(m){ this.mode = m; this.render(); }
update(){}
render(){
if (!this.el) return;
const W = this.W, H = this.H;
const cy = H / 2;
const mirrorX = W - 80; /* зеркало справа */
const F = (this.mode === 'concave') ? this.F : -this.F;
const focusX = mirrorX - F;
const centerX = mirrorX - 2 * F;
const d = this.d;
const objX = mirrorX - d;
/* Формула 1/d + 1/f = 1/F → f = 1/(1/F - 1/d) */
let f;
if (Math.abs(1/F - 1/d) < 1e-6) f = 1e9;
else f = 1 / (1/F - 1/d);
const imgX = mirrorX - f;
/* Линейное увеличение Γ = -f/d (мнимое: f<0 → прямое, действ.: f>0 → перевёрнутое) */
const G = -f / d;
const imgH = this.objH * G;
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Главная оптическая ось */
svg += '<line x1="20" y1="' + cy + '" x2="' + (W - 10) + '" y2="' + cy + '" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 3"/>';
/* Зеркало (дуга) */
const R = 2 * Math.abs(F);
if (this.mode === 'concave'){
svg += '<path d="M ' + mirrorX + ' ' + (cy - 100) + ' Q ' + (mirrorX - 30) + ' ' + cy + ' ' + mirrorX + ' ' + (cy + 100) + '" stroke="#0f172a" stroke-width="3" fill="none"/>';
} else {
svg += '<path d="M ' + mirrorX + ' ' + (cy - 100) + ' Q ' + (mirrorX + 30) + ' ' + cy + ' ' + mirrorX + ' ' + (cy + 100) + '" stroke="#0f172a" stroke-width="3" fill="none"/>';
}
/* Штриховка зеркала */
for (let i = 0; i < 8; i++){
const y = cy - 90 + i * 22;
const dx = this.mode === 'concave' ? 8 : -8;
svg += '<line x1="' + (mirrorX + (this.mode==='concave'?1:-1)) + '" y1="' + y + '" x2="' + (mirrorX + dx) + '" y2="' + (y + 6) + '" stroke="#0f172a" stroke-width="1.1"/>';
}
/* Точки F и C */
svg += '<circle cx="' + focusX + '" cy="' + cy + '" r="3.5" fill="#dc2626"/>';
svg += '<text x="' + focusX + '" y="' + (cy + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">F</text>';
if (this.mode === 'concave'){
svg += '<circle cx="' + centerX + '" cy="' + cy + '" r="3.5" fill="#1d4ed8"/>';
svg += '<text x="' + centerX + '" y="' + (cy + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#1d4ed8" font-weight="700">2F</text>';
}
/* Объект — красная стрелка вверх */
const objTopY = cy - this.objH;
svg += '<line x1="' + objX + '" y1="' + cy + '" x2="' + objX + '" y2="' + objTopY + '" stroke="' + this.color + '" stroke-width="3"/>';
svg += '<polygon points="' + objX + ',' + objTopY + ' ' + (objX - 6) + ',' + (objTopY + 10) + ' ' + (objX + 6) + ',' + (objTopY + 10) + '" fill="' + this.color + '"/>';
/* Лучи (3 канонических) */
/* Луч 1: параллельный оптической оси → отражается через F */
svg += '<line x1="' + objX + '" y1="' + objTopY + '" x2="' + mirrorX + '" y2="' + objTopY + '" stroke="#16a34a" stroke-width="1.6"/>';
if (this.mode === 'concave'){
svg += '<line x1="' + mirrorX + '" y1="' + objTopY + '" x2="' + focusX + '" y2="' + cy + '" stroke="#16a34a" stroke-width="1.6"/>';
/* Продолжение до изображения */
const slope1 = (cy - objTopY) / (focusX - mirrorX);
const x2 = imgX, y2 = cy + slope1 * (x2 - focusX);
svg += '<line x1="' + focusX + '" y1="' + cy + '" x2="' + x2 + '" y2="' + y2.toFixed(1) + '" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>';
} else {
/* Выпуклое: отражается так, словно вышел из мнимого F справа */
const slope = (objTopY - cy) / (mirrorX - focusX);
svg += '<line x1="' + mirrorX + '" y1="' + objTopY + '" x2="20" y2="' + (objTopY + slope * (20 - mirrorX)).toFixed(1) + '" stroke="#16a34a" stroke-width="1.6"/>';
svg += '<line x1="' + mirrorX + '" y1="' + objTopY + '" x2="' + (mirrorX + 50) + '" y2="' + (objTopY + slope * 50).toFixed(1) + '" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>';
}
/* Луч 2: через F → отражается параллельно оси (только вогнутое) */
if (this.mode === 'concave' && objX !== focusX){
const slope2 = (cy - objTopY) / (focusX - objX);
const hitY = objTopY + slope2 * (mirrorX - objX);
svg += '<line x1="' + objX + '" y1="' + objTopY + '" x2="' + mirrorX + '" y2="' + hitY.toFixed(1) + '" stroke="#1d4ed8" stroke-width="1.6"/>';
svg += '<line x1="' + mirrorX + '" y1="' + hitY.toFixed(1) + '" x2="' + Math.max(20, imgX) + '" y2="' + hitY.toFixed(1) + '" stroke="#1d4ed8" stroke-width="1.6"/>';
}
/* Изображение */
if (Math.abs(imgX) < 1e8 && imgX > 20 && imgX < mirrorX){
const imgTopY = cy - imgH;
const dashed = (this.mode === 'convex' || imgH > 0); /* мнимое = пунктир */
const isVirtual = (this.mode === 'convex') || (f < 0);
const sd = isVirtual ? ' stroke-dasharray="4 3"' : '';
const op = isVirtual ? 0.7 : 1.0;
svg += '<line x1="' + imgX + '" y1="' + cy + '" x2="' + imgX + '" y2="' + imgTopY + '" stroke="#7c2d12" stroke-width="2.6"' + sd + ' opacity="' + op + '"/>';
svg += '<polygon points="' + imgX + ',' + imgTopY + ' ' + (imgX - 5) + ',' + (imgTopY + (imgH > 0 ? 10 : -10)) + ' ' + (imgX + 5) + ',' + (imgTopY + (imgH > 0 ? 10 : -10)) + '" fill="#7c2d12" opacity="' + op + '"/>';
}
/* Подпись параметров */
const Glabel = isFinite(G) ? G.toFixed(2) : '—';
svg += '<text x="' + (W/2) + '" y="22" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">1/d + 1/f = 1/F · Γ = -f/d = ' + Glabel + '</text>';
svg += '<text x="' + (W/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#64748b">' + (this.mode==='concave'?'вогнутое':'выпуклое') + ' · F=' + Math.abs(F).toFixed(0) + 'px · d=' + d.toFixed(0) + 'px</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.SphericalMirror = SphericalMirror;
/* ============================================================ */
/* RefractionLab — преломление на границе двух сред (Снелл) */
/* ============================================================ */
class RefractionLab {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 540;
this.H = opts.height || 320;
this.n1 = opts.n1 !== undefined ? opts.n1 : 1.0; /* воздух */
this.n2 = opts.n2 !== undefined ? opts.n2 : 1.5; /* стекло */
this.alpha = opts.alpha !== undefined ? opts.alpha : 35; /* градусов */
this.color = opts.color || '#d97706';
this.paused = true;
this.render();
}
setN1(v){ this.n1 = Math.max(1, v); this.render(); }
setN2(v){ this.n2 = Math.max(1, v); this.render(); }
setAlpha(v){ this.alpha = Math.max(0, Math.min(89, v)); this.render(); }
update(){}
render(){
if (!this.el) return;
const W = this.W, H = this.H;
const cx = W / 2, cy = H / 2;
/* Углы (рад) */
const a = this.alpha * Math.PI / 180;
const sinB = this.n1 / this.n2 * Math.sin(a);
const totalInternal = Math.abs(sinB) > 1;
const b = totalInternal ? null : Math.asin(sinB);
/* Критический угол n1>n2 */
let critDeg = null;
if (this.n1 > this.n2) critDeg = Math.asin(this.n2 / this.n1) * 180 / Math.PI;
let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
/* Среда 1 (верх) */
svg += '<rect x="0" y="0" width="' + W + '" height="' + cy + '" fill="#e0f2fe" opacity="0.5"/>';
/* Среда 2 (низ) */
svg += '<rect x="0" y="' + cy + '" width="' + W + '" height="' + cy + '" fill="#fef3c7" opacity="0.6"/>';
/* Граница */
svg += '<line x1="0" y1="' + cy + '" x2="' + W + '" y2="' + cy + '" stroke="#0f172a" stroke-width="2"/>';
/* Нормаль (пунктир) */
svg += '<line x1="' + cx + '" y1="40" x2="' + cx + '" y2="' + (H - 40) + '" stroke="#94a3b8" stroke-width="1" stroke-dasharray="5 4"/>';
svg += '<text x="' + (cx + 8) + '" y="55" font-family="JetBrains Mono,monospace" font-size="10" fill="#64748b">нормаль</text>';
/* Падающий луч (из верхней-левой области) */
const L = 130;
const ix = cx - L * Math.sin(a), iy = cy - L * Math.cos(a);
svg += '<line x1="' + ix.toFixed(1) + '" y1="' + iy.toFixed(1) + '" x2="' + cx + '" y2="' + cy + '" stroke="#dc2626" stroke-width="2.4"/>';
/* Стрелка падающего */
const ang_i = Math.atan2(cy - iy, cx - ix);
const arrPx = cx - 30 * Math.cos(ang_i), arrPy = cy - 30 * Math.sin(ang_i);
svg += '<polygon points="' + (arrPx + 7 * Math.cos(ang_i - 0.4)).toFixed(1) + ',' + (arrPy + 7 * Math.sin(ang_i - 0.4)).toFixed(1) + ' ' + arrPx.toFixed(1) + ',' + arrPy.toFixed(1) + ' ' + (arrPx + 7 * Math.cos(ang_i + 0.4)).toFixed(1) + ',' + (arrPy + 7 * Math.sin(ang_i + 0.4)).toFixed(1) + '" fill="#dc2626"/>';
/* Дуга угла падения */
svg += '<path d="M ' + (cx - 30 * Math.sin(a/2)) + ' ' + (cy - 30 * Math.cos(a/2)) + ' A 30 30 0 0 1 ' + cx + ' ' + (cy - 30) + '" stroke="#dc2626" stroke-width="1.2" fill="none"/>';
svg += '<text x="' + (cx - 16 * Math.sin(a/2) - 12).toFixed(1) + '" y="' + (cy - 16 * Math.cos(a/2)).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">α</text>';
/* Отражённый луч */
const rx = cx + L * Math.sin(a), ry = cy - L * Math.cos(a);
svg += '<line x1="' + cx + '" y1="' + cy + '" x2="' + rx.toFixed(1) + '" y2="' + ry.toFixed(1) + '" stroke="#7c2d12" stroke-width="1.6" stroke-dasharray="4 2"/>';
svg += '<text x="' + (rx + 4).toFixed(1) + '" y="' + (ry - 4).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#7c2d12">отраж.</text>';
/* Преломлённый луч или полное отражение */
if (totalInternal){
svg += '<text x="' + (cx + 10) + '" y="' + (cy + 30) + '" font-family="Outfit,sans-serif" font-size="13" fill="#dc2626" font-weight="800">полное внутреннее отражение</text>';
} else {
const tx = cx + L * Math.sin(b), ty = cy + L * Math.cos(b);
svg += '<line x1="' + cx + '" y1="' + cy + '" x2="' + tx.toFixed(1) + '" y2="' + ty.toFixed(1) + '" stroke="#16a34a" stroke-width="2.4"/>';
const ang_t = Math.atan2(ty - cy, tx - cx);
const apx = cx + 30 * Math.cos(ang_t), apy = cy + 30 * Math.sin(ang_t);
svg += '<polygon points="' + (apx - 7 * Math.cos(ang_t - 0.4)).toFixed(1) + ',' + (apy - 7 * Math.sin(ang_t - 0.4)).toFixed(1) + ' ' + apx.toFixed(1) + ',' + apy.toFixed(1) + ' ' + (apx - 7 * Math.cos(ang_t + 0.4)).toFixed(1) + ',' + (apy - 7 * Math.sin(ang_t + 0.4)).toFixed(1) + '" fill="#16a34a"/>';
svg += '<path d="M ' + cx + ' ' + (cy + 30) + ' A 30 30 0 0 0 ' + (cx + 30 * Math.sin(b/2)) + ' ' + (cy + 30 * Math.cos(b/2)) + '" stroke="#16a34a" stroke-width="1.2" fill="none"/>';
svg += '<text x="' + (cx + 18 * Math.sin(b/2) + 4).toFixed(1) + '" y="' + (cy + 18 * Math.cos(b/2) + 4).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#16a34a" font-weight="700">β</text>';
}
/* Подписи сред */
svg += '<text x="20" y="22" font-family="JetBrains Mono,monospace" font-size="11" fill="#0369a1" font-weight="700">n₁ = ' + this.n1.toFixed(2) + '</text>';
svg += '<text x="20" y="' + (H - 12) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#a16207" font-weight="700">n₂ = ' + this.n2.toFixed(2) + '</text>';
/* Формула + углы */
const bDeg = totalInternal ? '—' : (b * 180 / Math.PI).toFixed(1);
svg += '<text x="' + (W - 12) + '" y="22" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">n₁ sin α = n₂ sin β</text>';
svg += '<text x="' + (W - 12) + '" y="' + (H - 12) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">α=' + this.alpha.toFixed(0) + '° · β=' + bDeg + '°' + (critDeg!==null?' · αкр=' + critDeg.toFixed(1) + '°':'') + '</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.RefractionLab = RefractionLab;
/* ============================================================ */
/* PrismSpectrum — призма, дисперсия белого света */
/* ============================================================ */
class PrismSpectrum {
constructor(container, opts){
opts = opts || {};
this.el = (typeof container === 'string') ? document.querySelector(container) : container;
this.W = opts.width || 580;
this.H = opts.height || 260;
this.alpha = opts.alpha !== undefined ? opts.alpha : 50; /* угол падения, градусы */
this.paused = true;
this.render();
}
setAlpha(v){ this.alpha = Math.max(20, Math.min(75, v)); this.render(); }
update(){}
/* Показатели преломления стекла для разных длин волн (упрощённая модель Коши) */
nForColor(lamNm){
return 1.5 + 6500 / (lamNm * lamNm); /* красный ~1.518, фиолет ~1.530 */
}
render(){
if (!this.el) return;
const W = this.W, H = this.H;
/* Геометрия равностороннего треугольника-призмы */
const cx = W / 2, cy = H / 2 + 10;
const side = 140;
const h = side * Math.sqrt(3) / 2;
const A = { x: cx, y: cy - h * 2/3 }; /* верхняя вершина */
const B = { x: cx - side/2, y: cy + h/3 }; /* нижняя левая */
const C = { x: cx + side/2, y: cy + h/3 }; /* нижняя правая */
let svg = util.svgFrame(W, H, {bg:'#0f172a'});
/* Призма (полупрозрачная) */
svg += '<polygon points="' + A.x + ',' + A.y.toFixed(1) + ' ' + B.x + ',' + B.y.toFixed(1) + ' ' + C.x + ',' + C.y.toFixed(1) + '" fill="rgba(255,255,255,0.08)" stroke="#cbd5e1" stroke-width="1.6"/>';
/* Падающий белый луч на грань AB */
const a = this.alpha * Math.PI / 180;
/* Точка входа — середина грани AB */
const Pin = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
/* Нормаль к AB (наружу, влево-вверх) */
const ABx = B.x - A.x, ABy = B.y - A.y;
const Lab = Math.hypot(ABx, ABy);
const nABx = -ABy / Lab, nABy = ABx / Lab; /* перпендикуляр */
/* Источник падающего луча */
const inLen = 160;
const inx = Pin.x + inLen * (nABx * Math.cos(a) - (ABx/Lab) * Math.sin(a));
const iny = Pin.y + inLen * (nABy * Math.cos(a) - (ABy/Lab) * Math.sin(a));
svg += '<line x1="' + inx.toFixed(1) + '" y1="' + iny.toFixed(1) + '" x2="' + Pin.x.toFixed(1) + '" y2="' + Pin.y.toFixed(1) + '" stroke="#fff" stroke-width="3"/>';
svg += '<text x="' + (inx - 8).toFixed(1) + '" y="' + (iny - 6).toFixed(1) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#fff">белый</text>';
/* 7 цветов спектра — каждый со своим n и углом преломления */
const colors = [
{ lam: 700, c: '#dc2626' }, /* красный */
{ lam: 620, c: '#ea580c' }, /* оранжевый */
{ lam: 580, c: '#facc15' }, /* жёлтый */
{ lam: 520, c: '#16a34a' }, /* зелёный */
{ lam: 480, c: '#06b6d4' }, /* голубой */
{ lam: 450, c: '#1d4ed8' }, /* синий */
{ lam: 420, c: '#7c3aed' } /* фиолетовый */
];
/* Нормаль к BC (наружу, вправо-вниз) */
const BCx = C.x - B.x, BCy = C.y - B.y;
const Lbc = Math.hypot(BCx, BCy);
const nBCx = BCy / Lbc, nBCy = -BCx / Lbc;
/* Точки выхода — равномерно по грани BC */
const outLen = 180;
for (let i = 0; i < colors.length; i++){
const co = colors[i];
const n = this.nForColor(co.lam);
/* Преломление при входе: sin β = sin α / n */
const sinB = Math.sin(a) / n;
if (Math.abs(sinB) > 1) continue;
const beta = Math.asin(sinB);
/* Внутри призмы луч идёт от Pin под углом beta от нормали к AB, в сторону BC */
/* Направление внутри: поворот нормали-в-стекло (-nAB) на beta */
const dirX = -nABx * Math.cos(beta) + (ABx/Lab) * Math.sin(beta);
const dirY = -nABy * Math.cos(beta) + (ABy/Lab) * Math.sin(beta);
/* Найти точку выхода на BC (параметрический луч / линия BC) */
const denom = dirX * (-(C.y - B.y)) + dirY * (C.x - B.x);
if (Math.abs(denom) < 1e-6) continue;
const t = ((B.x - Pin.x) * (-(C.y - B.y)) + (B.y - Pin.y) * (C.x - B.x)) / denom;
const Pout = { x: Pin.x + t * dirX, y: Pin.y + t * dirY };
/* Луч внутри */
svg += '<line x1="' + Pin.x.toFixed(1) + '" y1="' + Pin.y.toFixed(1) + '" x2="' + Pout.x.toFixed(1) + '" y2="' + Pout.y.toFixed(1) + '" stroke="' + co.c + '" stroke-width="1.6" opacity="0.85"/>';
/* Преломление при выходе: угол к нормали BC внутри */
const cosIn = -(dirX * nBCx + dirY * nBCy); /* направление к внешней нормали */
const sinIn = Math.sqrt(Math.max(0, 1 - cosIn * cosIn));
const sinOut = sinIn * n;
if (sinOut > 1) continue;
const cosOut = Math.sqrt(1 - sinOut * sinOut);
/* Тангенциальная составляющая (вдоль BC) */
const tBCx = BCx / Lbc, tBCy = BCy / Lbc;
const tanSign = (dirX * tBCx + dirY * tBCy) >= 0 ? 1 : -1;
const outX = nBCx * cosOut + tBCx * sinOut * tanSign;
const outY = nBCy * cosOut + tBCy * sinOut * tanSign;
const Pend = { x: Pout.x + outLen * outX, y: Pout.y + outLen * outY };
svg += '<line x1="' + Pout.x.toFixed(1) + '" y1="' + Pout.y.toFixed(1) + '" x2="' + Pend.x.toFixed(1) + '" y2="' + Pend.y.toFixed(1) + '" stroke="' + co.c + '" stroke-width="2.4"/>';
}
/* Подпись */
svg += '<text x="' + (W/2) + '" y="20" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#cbd5e1" font-weight="700">дисперсия: n(λ) растёт при уменьшении λ → фиолетовый отклоняется сильнее красного</text>';
svg += '<text x="' + (W/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#94a3b8">α = ' + this.alpha.toFixed(0) + '° · стекло (модель Коши)</text>';
svg += '</svg>';
this.el.innerHTML = svg;
}
}
P.PrismSpectrum = PrismSpectrum;
})();