/* 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;

/* ============================================================ */
/*  ThinLens — тонкая линза (собирающая / рассеивающая)          */
/* ============================================================ */

class ThinLens {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 640;
    this.H = opts.height || 300;
    this.F = opts.F !== undefined ? opts.F : 70;      /* фокусное (px) */
    this.d = opts.d !== undefined ? opts.d : 160;     /* расст. до объекта (px) */
    this.objH = opts.objH !== undefined ? opts.objH : 50;
    this.mode = opts.mode || 'converging';            /* 'converging' | 'diverging' */
    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 + 10;
    const lensX = W / 2;
    const Fsign = (this.mode === 'converging') ? this.F : -this.F;
    const d = this.d;
    /* 1/d + 1/f = 1/F → f = 1/(1/F - 1/d) */
    let f;
    if (Math.abs(1/Fsign - 1/d) < 1e-6) f = 1e9;
    else f = 1 / (1/Fsign - 1/d);
    /* По соглашению: f > 0 справа (действительное), f < 0 слева (мнимое) */
    const imgX = lensX + f;
    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"/>';
    /* Линза */
    if (this.mode === 'converging'){
      svg += '<line x1="' + lensX + '" y1="40" x2="' + lensX + '" y2="' + (H - 40) + '" stroke="#0f172a" stroke-width="3"/>';
      svg += '<polygon points="' + lensX + ',32 ' + (lensX - 7) + ',46 ' + (lensX + 7) + ',46" fill="#0f172a"/>';
      svg += '<polygon points="' + lensX + ',' + (H - 32) + ' ' + (lensX - 7) + ',' + (H - 46) + ' ' + (lensX + 7) + ',' + (H - 46) + '" fill="#0f172a"/>';
    } else {
      svg += '<line x1="' + lensX + '" y1="40" x2="' + lensX + '" y2="' + (H - 40) + '" stroke="#0f172a" stroke-width="3" stroke-dasharray="2 2"/>';
      svg += '<polygon points="' + (lensX - 8) + ',32 ' + lensX + ',46 ' + (lensX + 8) + ',32" fill="#0f172a"/>';
      svg += '<polygon points="' + (lensX - 8) + ',' + (H - 32) + ' ' + lensX + ',' + (H - 46) + ' ' + (lensX + 8) + ',' + (H - 32) + '" fill="#0f172a"/>';
    }
    /* Фокусы F и 2F */
    const Fleft = lensX - this.F, Fright = lensX + this.F;
    svg += '<circle cx="' + Fleft + '" cy="' + cy + '" r="3.5" fill="#dc2626"/><text x="' + Fleft + '" y="' + (cy + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">F</text>';
    svg += '<circle cx="' + Fright + '" cy="' + cy + '" r="3.5" fill="#dc2626"/><text x="' + Fright + '" y="' + (cy + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">F</text>';
    if (this.mode === 'converging'){
      const F2L = lensX - 2*this.F, F2R = lensX + 2*this.F;
      if (F2L > 20) svg += '<circle cx="' + F2L + '" cy="' + cy + '" r="3" fill="#1d4ed8"/><text x="' + F2L + '" y="' + (cy + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#1d4ed8">2F</text>';
      if (F2R < W - 10) svg += '<circle cx="' + F2R + '" cy="' + cy + '" r="3" fill="#1d4ed8"/><text x="' + F2R + '" y="' + (cy + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#1d4ed8">2F</text>';
    }
    /* Объект */
    const objX = lensX - d;
    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 + '"/>';
    /* Лучи */
    /* Луч 1: параллельный → через F справа (для собирающей); для рассеивающей — как-будто из F слева */
    svg += '<line x1="' + objX + '" y1="' + objTopY + '" x2="' + lensX + '" y2="' + objTopY + '" stroke="#16a34a" stroke-width="1.6"/>';
    if (this.mode === 'converging'){
      const slope = (cy - objTopY) / (Fright - lensX);
      const x2 = Math.min(W - 10, imgX > lensX ? imgX : W - 10);
      const y2 = objTopY + slope * (x2 - lensX);
      svg += '<line x1="' + lensX + '" y1="' + objTopY + '" x2="' + x2.toFixed(1) + '" y2="' + y2.toFixed(1) + '" stroke="#16a34a" stroke-width="1.6"/>';
    } else {
      /* Рассеивающая: продолжение в сторону F слева */
      const slope = (objTopY - cy) / (lensX - Fleft);
      svg += '<line x1="' + lensX + '" y1="' + objTopY + '" x2="' + (W - 10) + '" y2="' + (objTopY + slope * (W - 10 - lensX)).toFixed(1) + '" stroke="#16a34a" stroke-width="1.6"/>';
      svg += '<line x1="' + lensX + '" y1="' + objTopY + '" x2="' + Fleft + '" y2="' + cy + '" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>';
    }
    /* Луч 2: через оптический центр O (без преломления) */
    const slope2 = (cy - objTopY) / (lensX - objX);
    const ex = Math.min(W - 10, imgX > lensX ? imgX : W - 10);
    const ey = objTopY + slope2 * (ex - objX);
    svg += '<line x1="' + objX + '" y1="' + objTopY + '" x2="' + ex.toFixed(1) + '" y2="' + ey.toFixed(1) + '" stroke="#1d4ed8" stroke-width="1.6"/>';
    /* Изображение */
    if (isFinite(imgH) && Math.abs(imgX) < 1e7){
      const imgTopY = cy - imgH;
      const isVirtual = (this.mode === 'diverging') || (f < 0);
      const sd = isVirtual ? ' stroke-dasharray="4 3"' : '';
      const op = isVirtual ? 0.7 : 1.0;
      const fillCol = '#7c2d12';
      if (imgX > 20 && imgX < W - 10){
        svg += '<line x1="' + imgX.toFixed(1) + '" y1="' + cy + '" x2="' + imgX.toFixed(1) + '" y2="' + imgTopY.toFixed(1) + '" stroke="' + fillCol + '" stroke-width="2.6"' + sd + ' opacity="' + op + '"/>';
        svg += '<polygon points="' + imgX.toFixed(1) + ',' + imgTopY.toFixed(1) + ' ' + (imgX - 5).toFixed(1) + ',' + (imgTopY + (imgH > 0 ? 10 : -10)).toFixed(1) + ' ' + (imgX + 5).toFixed(1) + ',' + (imgTopY + (imgH > 0 ? 10 : -10)).toFixed(1) + '" fill="' + fillCol + '" opacity="' + op + '"/>';
      }
    }
    /* Подписи */
    const Glabel = isFinite(G) ? G.toFixed(2) : '—';
    const fLabel = (Math.abs(f) < 1e7) ? f.toFixed(0) : '∞';
    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 + '  ·  f = ' + fLabel + 'px</text>';
    svg += '<text x="' + (W/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#64748b">' + (this.mode==='converging'?'собирающая':'рассеивающая') + ' линза · F=' + this.F + 'px · d=' + d + 'px</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
}
P.ThinLens = ThinLens;

/* ============================================================ */
/*  TwoLensSystem — две линзы (микроскоп / телескоп)             */
/* ============================================================ */

class TwoLensSystem {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 700;
    this.H = opts.height || 280;
    this.F1 = opts.F1 !== undefined ? opts.F1 : 50;    /* объектив */
    this.F2 = opts.F2 !== undefined ? opts.F2 : 90;    /* окуляр */
    this.L = opts.L !== undefined ? opts.L : 280;      /* расстояние между линзами */
    this.mode = opts.mode || 'telescope';              /* 'telescope' | 'microscope' */
    this.paused = true;
    this.render();
  }
  setMode(m){ this.mode = m; this.render(); }
  setF1(v){ this.F1 = Math.max(20, v); this.render(); }
  setF2(v){ this.F2 = Math.max(20, v); this.render(); }
  setL(v){ this.L = Math.max(this.F1 + this.F2 + 20, v); this.render(); }
  update(){}
  drawLens(svg, x, cy, label, F){
    let s = svg;
    s += '<line x1="' + x + '" y1="' + (cy - 80) + '" x2="' + x + '" y2="' + (cy + 80) + '" stroke="#0f172a" stroke-width="2.6"/>';
    s += '<polygon points="' + x + ',' + (cy - 88) + ' ' + (x - 6) + ',' + (cy - 76) + ' ' + (x + 6) + ',' + (cy - 76) + '" fill="#0f172a"/>';
    s += '<polygon points="' + x + ',' + (cy + 88) + ' ' + (x - 6) + ',' + (cy + 76) + ' ' + (x + 6) + ',' + (cy + 76) + '" fill="#0f172a"/>';
    s += '<text x="' + x + '" y="' + (cy + 102) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">' + label + '</text>';
    s += '<circle cx="' + (x - F) + '" cy="' + cy + '" r="2.5" fill="#dc2626"/>';
    s += '<circle cx="' + (x + F) + '" cy="' + cy + '" r="2.5" fill="#dc2626"/>';
    return s;
  }
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const cy = H / 2 + 10;
    const x1 = 130;            /* объектив */
    const x2 = x1 + this.L;    /* окуляр */
    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"/>';
    /* Линзы */
    svg = this.drawLens(svg, x1, cy, 'объектив (F₁=' + this.F1 + ')', this.F1);
    svg = this.drawLens(svg, x2, cy, 'окуляр (F₂=' + this.F2 + ')', this.F2);
    /* Сценарий */
    if (this.mode === 'telescope'){
      /* Объект «на бесконечности» — пучок параллельных лучей входит слева */
      const angle = -0.06;     /* небольшой угол */
      for (let i = -2; i <= 2; i++){
        const yIn = cy + i * 18;
        /* Луч входит горизонтально (или под малым углом α₁) */
        const xIn = 25;
        svg += '<line x1="' + xIn + '" y1="' + yIn + '" x2="' + x1 + '" y2="' + yIn + '" stroke="#16a34a" stroke-width="1.4"/>';
        /* После объектива собирается в фокальной плоскости (x1 + F1) */
        const focusX = x1 + this.F1;
        const focusY = cy;     /* для параллельного пучка вдоль оси — на оси */
        svg += '<line x1="' + x1 + '" y1="' + yIn + '" x2="' + focusX + '" y2="' + focusY + '" stroke="#16a34a" stroke-width="1.4"/>';
        /* От фокуса (F2 справа от окуляра у телескопа: совмещён с F1 объектива) дальше параллельно */
        /* Лучи проходят через окуляр */
        const yAtEye = focusY + (cy + i * 12 - focusY);   /* выходные углы немного шире */
        svg += '<line x1="' + focusX + '" y1="' + focusY + '" x2="' + x2 + '" y2="' + (cy + i * 8) + '" stroke="#16a34a" stroke-width="1.2" stroke-dasharray="3 3"/>';
        /* После окуляра — параллельно (изображение в бесконечности) */
        svg += '<line x1="' + x2 + '" y1="' + (cy + i * 8) + '" x2="' + (W - 20) + '" y2="' + (cy + i * 14) + '" stroke="#16a34a" stroke-width="1.4"/>';
      }
      svg += '<text x="60" y="40" font-family="JetBrains Mono,monospace" font-size="11" fill="#1d4ed8" font-weight="700">пучок от удалённого объекта</text>';
      svg += '<text x="' + (W - 20) + '" y="40" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#1d4ed8" font-weight="700">в глаз наблюдателя</text>';
      svg += '<text x="' + (W/2) + '" y="' + (H - 10) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569">телескоп Кеплера · Γ = F₁/F₂ = ' + (this.F1/this.F2).toFixed(2) + '</text>';
    } else {
      /* Микроскоп: объект чуть дальше F1 от объектива */
      const d1 = this.F1 + 12;
      const objX = x1 - d1;
      const objH = 40;
      /* 1/d + 1/f = 1/F */
      const f1 = 1 / (1/this.F1 - 1/d1);
      const G1 = -f1 / d1;
      const h1 = objH * G1;
      const img1X = x1 + f1;
      /* Изображение объектива должно лежать чуть ближе F2 от окуляра, чтобы окуляр работал как лупа */
      /* Объект */
      svg += '<line x1="' + objX + '" y1="' + cy + '" x2="' + objX + '" y2="' + (cy - objH) + '" stroke="#d97706" stroke-width="3"/>';
      svg += '<polygon points="' + objX + ',' + (cy - objH) + ' ' + (objX - 5) + ',' + (cy - objH + 9) + ' ' + (objX + 5) + ',' + (cy - objH + 9) + '" fill="#d97706"/>';
      /* Промежуточное изображение */
      if (img1X > x1 && img1X < x2){
        svg += '<line x1="' + img1X.toFixed(1) + '" y1="' + cy + '" x2="' + img1X.toFixed(1) + '" y2="' + (cy - h1).toFixed(1) + '" stroke="#7c2d12" stroke-width="2.4"/>';
        svg += '<polygon points="' + img1X.toFixed(1) + ',' + (cy - h1).toFixed(1) + ' ' + (img1X - 4).toFixed(1) + ',' + (cy - h1 + (h1>0?9:-9)).toFixed(1) + ' ' + (img1X + 4).toFixed(1) + ',' + (cy - h1 + (h1>0?9:-9)).toFixed(1) + '" fill="#7c2d12"/>';
        svg += '<text x="' + img1X.toFixed(1) + '" y="' + (cy + 14) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="9" fill="#7c2d12">A₁B₁</text>';
      }
      /* Лучи от верха объекта через объектив (2 канонических) */
      svg += '<line x1="' + objX + '" y1="' + (cy - objH) + '" x2="' + x1 + '" y2="' + (cy - objH) + '" stroke="#16a34a" stroke-width="1.4"/>';
      svg += '<line x1="' + x1 + '" y1="' + (cy - objH) + '" x2="' + img1X.toFixed(1) + '" y2="' + (cy - h1).toFixed(1) + '" stroke="#16a34a" stroke-width="1.4"/>';
      /* Через оптический центр объектива */
      svg += '<line x1="' + objX + '" y1="' + (cy - objH) + '" x2="' + img1X.toFixed(1) + '" y2="' + (cy - h1).toFixed(1) + '" stroke="#1d4ed8" stroke-width="1.4"/>';
      /* Окуляр работает как лупа — даёт мнимое увеличенное (за окуляром слева) */
      /* Показ лучей от верха промежуточного изображения через окуляр параллельно (в глаз) */
      svg += '<line x1="' + img1X.toFixed(1) + '" y1="' + (cy - h1).toFixed(1) + '" x2="' + x2 + '" y2="' + (cy - h1).toFixed(1) + '" stroke="#16a34a" stroke-width="1.4"/>';
      svg += '<line x1="' + x2 + '" y1="' + (cy - h1).toFixed(1) + '" x2="' + (W - 20) + '" y2="' + (cy - h1 * 1.6).toFixed(1) + '" stroke="#16a34a" stroke-width="1.4"/>';
      svg += '<text x="' + (W - 20) + '" y="40" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#1d4ed8" font-weight="700">в глаз</text>';
      const Gtotal = G1 * (25 / this.F2);   /* приближённо: |Γ_мкс| ≈ |Γ_объ| · 25 см/F_ок */
      svg += '<text x="' + (W/2) + '" y="' + (H - 10) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569">микроскоп · Γ ≈ Γ_объ · 25 см / F_ок ≈ ' + Gtotal.toFixed(1) + '</text>';
    }
    this.el.innerHTML = svg;
  }
}
P.TwoLensSystem = TwoLensSystem;

/* ============================================================ */
/*  GammaPlot — график γ(β) и τ/τ₀, L/L₀                         */
/* ============================================================ */

class GammaPlot {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 580;
    this.H = opts.height || 280;
    this.beta = opts.beta !== undefined ? opts.beta : 0.5;   /* v/c */
    this.color = opts.color || '#2563eb';
    this.paused = true;
    this.render();
  }
  setBeta(v){ this.beta = Math.max(0, Math.min(0.999, v)); this.render(); }
  update(){}
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const pad = 40;
    const left = pad, right = W - pad - 100, top = 30, bot = H - 40;
    let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
    /* Сетка */
    svg += '<g stroke="#e2e8f0" stroke-width="0.8">';
    for (let i = 0; i <= 10; i++){
      const x = left + i * (right - left) / 10;
      svg += '<line x1="' + x + '" y1="' + top + '" x2="' + x + '" y2="' + bot + '"/>';
    }
    for (let i = 0; i <= 6; i++){
      const y = top + i * (bot - top) / 6;
      svg += '<line x1="' + left + '" y1="' + y + '" x2="' + right + '" y2="' + y + '"/>';
    }
    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="' + ((left + right) / 2) + '" y="' + (bot + 26) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569">β = v/c</text>';
    svg += '<text x="' + (left - 24) + '" y="' + ((top + bot) / 2) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" transform="rotate(-90 ' + (left - 24) + ' ' + ((top + bot) / 2) + ')">γ</text>';
    /* γ — растёт; ограничим до 6 */
    let pts = '';
    for (let i = 0; i <= 100; i++){
      const b = i / 100 * 0.99;
      const g = 1 / Math.sqrt(1 - b * b);
      const x = left + b * (right - left);
      const y = bot - Math.min(6, g) * (bot - top) / 6;
      pts += x.toFixed(1) + ',' + y.toFixed(1) + ' ';
    }
    svg += '<polyline points="' + pts + '" fill="none" stroke="' + this.color + '" stroke-width="2.4"/>';
    /* γ = 1 базовая линия */
    svg += '<line x1="' + left + '" y1="' + (bot - (bot - top) / 6) + '" x2="' + right + '" y2="' + (bot - (bot - top) / 6) + '" stroke="#94a3b8" stroke-width="0.8" stroke-dasharray="3 3"/>';
    svg += '<text x="' + (left - 6) + '" y="' + (bot - (bot - top) / 6 + 4) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#94a3b8">1</text>';
    /* Текущая точка */
    const g = 1 / Math.sqrt(1 - this.beta * this.beta);
    const cx = left + this.beta * (right - left);
    const cy = bot - Math.min(6, g) * (bot - top) / 6;
    svg += '<line x1="' + cx + '" y1="' + bot + '" x2="' + cx + '" y2="' + cy.toFixed(1) + '" stroke="#dc2626" stroke-width="1" stroke-dasharray="3 3"/>';
    svg += '<circle cx="' + cx + '" cy="' + cy.toFixed(1) + '" r="5" fill="#dc2626"/>';
    /* Подпись значения */
    const panelX = right + 12;
    svg += '<text x="' + panelX + '" y="' + (top + 14) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">β = ' + this.beta.toFixed(3) + '</text>';
    svg += '<text x="' + panelX + '" y="' + (top + 32) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#2563eb" font-weight="700">γ = ' + g.toFixed(3) + '</text>';
    svg += '<text x="' + panelX + '" y="' + (top + 50) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">τ = γτ₀</text>';
    svg += '<text x="' + panelX + '" y="' + (top + 64) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">L = L₀/γ</text>';
    svg += '<text x="' + panelX + '" y="' + (top + 82) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">τ/τ₀ = ' + g.toFixed(2) + '</text>';
    svg += '<text x="' + panelX + '" y="' + (top + 96) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">L/L₀ = ' + (1 / g).toFixed(2) + '</text>';
    svg += '<text x="' + (W/2) + '" y="20" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">γ = 1/√(1 - β²)</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
}
P.GammaPlot = GammaPlot;

/* ============================================================ */
/*  TimeDilation — двое часов: «покоящиеся» и «движущиеся»       */
/* ============================================================ */

class TimeDilation {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 600;
    this.H = opts.height || 240;
    this.beta = opts.beta !== undefined ? opts.beta : 0.5;
    this.t = 0;
    this.paused = false;
    this._render();
    util.subscribe(this);
    util.observe(this);
  }
  setBeta(v){ this.beta = Math.max(0, Math.min(0.99, v)); }
  update(dt){ this.t += dt; }
  drawClock(svg, cx, cy, r, time, label, color){
    let s = svg;
    s += '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="#fff" stroke="' + color + '" stroke-width="2.4"/>';
    /* Метки часов 12, 3, 6, 9 */
    for (let i = 0; i < 12; i++){
      const a = i * Math.PI / 6;
      const x1 = cx + (r - 6) * Math.sin(a), y1 = cy - (r - 6) * Math.cos(a);
      const x2 = cx + (r - 2) * Math.sin(a), y2 = cy - (r - 2) * Math.cos(a);
      s += '<line x1="' + x1.toFixed(1) + '" y1="' + y1.toFixed(1) + '" x2="' + x2.toFixed(1) + '" y2="' + y2.toFixed(1) + '" stroke="#475569" stroke-width="' + (i % 3 === 0 ? 2 : 1) + '"/>';
    }
    /* Стрелка секундная (один оборот = 6 «времени») */
    const a = (time / 6) * 2 * Math.PI;
    const hx = cx + (r - 12) * Math.sin(a), hy = cy - (r - 12) * Math.cos(a);
    s += '<line x1="' + cx + '" y1="' + cy + '" x2="' + hx.toFixed(1) + '" y2="' + hy.toFixed(1) + '" stroke="' + color + '" stroke-width="2.6" stroke-linecap="round"/>';
    s += '<circle cx="' + cx + '" cy="' + cy + '" r="3" fill="' + color + '"/>';
    s += '<text x="' + cx + '" y="' + (cy + r + 22) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="' + color + '" font-weight="700">' + label + '</text>';
    s += '<text x="' + cx + '" y="' + (cy + r + 38) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">t = ' + time.toFixed(2) + ' с</text>';
    return s;
  }
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const g = 1 / Math.sqrt(1 - this.beta * this.beta);
    /* Часы покоя — реальное время */
    const t0 = this.t;
    /* Движущиеся — идут медленнее в γ раз для наблюдателя */
    const tmov = this.t / g;
    let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
    svg = this.drawClock(svg, 140, 100, 55, t0, 'часы наблюдателя', '#0f172a');
    svg = this.drawClock(svg, W - 140, 100, 55, tmov, 'часы в движ. системе', '#dc2626');
    /* Стрелка движения */
    svg += '<line x1="220" y1="' + (H - 30) + '" x2="' + (W - 220) + '" y2="' + (H - 30) + '" stroke="#94a3b8" stroke-width="1.4"/>';
    svg += '<polygon points="' + (W - 220) + ',' + (H - 30) + ' ' + (W - 232) + ',' + (H - 36) + ' ' + (W - 232) + ',' + (H - 24) + '" fill="#94a3b8"/>';
    svg += '<text x="' + (W/2) + '" y="' + (H - 36) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569">v → · β = ' + this.beta.toFixed(2) + ' · γ = ' + g.toFixed(2) + '</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;
  }
  _render(){ this.render(); }
}
P.TimeDilation = TimeDilation;

/* ============================================================ */
/*  LengthContraction — стержень в покое и в движении            */
/* ============================================================ */

class LengthContraction {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 600;
    this.H = opts.height || 220;
    this.beta = opts.beta !== undefined ? opts.beta : 0.5;
    this.L0 = opts.L0 !== undefined ? opts.L0 : 320;
    this.paused = true;
    this.render();
  }
  setBeta(v){ this.beta = Math.max(0, Math.min(0.99, v)); this.render(); }
  update(){}
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const g = 1 / Math.sqrt(1 - this.beta * this.beta);
    const L = this.L0 / g;
    let svg = util.svgFrame(W, H, {bg:'#f8fafc'});
    /* Подпись */
    svg += '<text x="' + (W/2) + '" y="20" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">L = L₀ · √(1 - β²) = L₀/γ</text>';
    /* Стержень в покое */
    const cx = W / 2;
    const y1 = 60;
    svg += '<text x="' + (cx - this.L0/2 - 10) + '" y="' + (y1 + 5) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">покой:</text>';
    svg += '<rect x="' + (cx - this.L0/2) + '" y="' + (y1 - 12) + '" width="' + this.L0 + '" height="24" fill="#dbeafe" stroke="#0f172a" stroke-width="1.6"/>';
    /* Деления */
    for (let i = 0; i <= 10; i++){
      const x = cx - this.L0/2 + i * this.L0 / 10;
      svg += '<line x1="' + x + '" y1="' + (y1 - 12) + '" x2="' + x + '" y2="' + (y1 + 12) + '" stroke="#0f172a" stroke-width="0.8"/>';
    }
    svg += '<text x="' + cx + '" y="' + (y1 + 32) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a">L₀ = ' + this.L0 + ' (собственная длина)</text>';
    /* Стержень в движении */
    const y2 = 140;
    svg += '<text x="' + (cx - L/2 - 10) + '" y="' + (y2 + 5) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">движется:</text>';
    svg += '<rect x="' + (cx - L/2) + '" y="' + (y2 - 12) + '" width="' + L.toFixed(1) + '" height="24" fill="#fee2e2" stroke="#dc2626" stroke-width="1.6"/>';
    for (let i = 0; i <= 10; i++){
      const x = cx - L/2 + i * L / 10;
      svg += '<line x1="' + x.toFixed(1) + '" y1="' + (y2 - 12) + '" x2="' + x.toFixed(1) + '" y2="' + (y2 + 12) + '" stroke="#dc2626" stroke-width="0.8"/>';
    }
    /* Стрелка скорости */
    svg += '<line x1="' + (cx + L/2 + 10) + '" y1="' + y2 + '" x2="' + (cx + L/2 + 60) + '" y2="' + y2 + '" stroke="#dc2626" stroke-width="2"/>';
    svg += '<polygon points="' + (cx + L/2 + 60) + ',' + y2 + ' ' + (cx + L/2 + 50) + ',' + (y2 - 6) + ' ' + (cx + L/2 + 50) + ',' + (y2 + 6) + '" fill="#dc2626"/>';
    svg += '<text x="' + (cx + L/2 + 30) + '" y="' + (y2 - 10) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#dc2626" font-weight="700">v</text>';
    svg += '<text x="' + cx + '" y="' + (y2 + 32) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626">L = ' + L.toFixed(1) + ' · L/L₀ = ' + (1/g).toFixed(3) + '</text>';
    /* Параметры */
    svg += '<text x="' + (W - 12) + '" y="' + (H - 10) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">β = ' + this.beta.toFixed(2) + ' · γ = ' + g.toFixed(2) + '</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
}
P.LengthContraction = LengthContraction;

/* ============================================================ */
/*  PhotoeffectLab — катод, свет, цепь, амперметр                */
/* ============================================================ */

class PhotoeffectLab {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 580;
    this.H = opts.height || 280;
    this.nu = opts.nu !== undefined ? opts.nu : 8e14;       /* Гц */
    this.nu0 = opts.nu0 !== undefined ? opts.nu0 : 5.5e14;  /* красная граница */
    this.U = opts.U !== undefined ? opts.U : 0;             /* В, тормозящее < 0 */
    this.phase = 0;
    this.paused = false;
    util.subscribe(this);
    util.observe(this);
    this._render();
  }
  setNu(v){ this.nu = v; }
  setNu0(v){ this.nu0 = v; }
  setU(v){ this.U = v; }
  /* Кинетическая энергия фотоэлектронов: hν - A (А = hν₀) */
  ekMax(){
    const h = 6.63e-34;
    return Math.max(0, h * (this.nu - this.nu0));
  }
  /* Электроны летят, если их Eк больше тормозящего eU */
  flying(){
    const e = 1.6e-19;
    return this.nu > this.nu0 && this.ekMax() > e * Math.max(0, -this.U);
  }
  update(dt){ this.phase += dt * 4; }
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    let svg = util.svgFrame(W, H, {bg:'#fef3c7'});
    /* Лампа-источник */
    const lx = 50, ly = 60;
    svg += '<circle cx="' + lx + '" cy="' + ly + '" r="22" fill="#fde68a" stroke="#f59e0b" stroke-width="2"/>';
    svg += '<text x="' + lx + '" y="' + (ly + 5) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#92400e" font-weight="700">hν</text>';
    /* Луч света — цвет зависит от ν: 4e14 (красн) до 1e15 (фиолет) */
    const nuRel = Math.max(0, Math.min(1, (this.nu - 4e14) / 6e14));
    const lightColor = `rgb(${Math.round(255*(1-nuRel))},${Math.round(80+120*(1-Math.abs(nuRel-0.5)*2))},${Math.round(255*nuRel)})`;
    for (let i = 0; i < 6; i++){
      const yo = (this.phase * 30 + i * 22) % 120;
      svg += '<line x1="' + (lx + 22) + '" y1="' + (ly - 30 + yo) + '" x2="' + (lx + 100) + '" y2="' + (ly + 50 + yo) + '" stroke="' + lightColor + '" stroke-width="2" opacity="0.7"/>';
    }
    /* Катод */
    const cx = 180, cyy = 140;
    svg += '<rect x="' + cx + '" y="' + (cyy - 40) + '" width="14" height="80" fill="#475569" stroke="#0f172a" stroke-width="1.6"/>';
    svg += '<text x="' + (cx + 7) + '" y="' + (cyy + 60) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">катод (-)</text>';
    /* Анод */
    const ax = 380;
    svg += '<rect x="' + ax + '" y="' + (cyy - 40) + '" width="14" height="80" fill="#94a3b8" stroke="#0f172a" stroke-width="1.6"/>';
    svg += '<text x="' + (ax + 7) + '" y="' + (cyy + 60) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">анод (' + (this.U >= 0 ? '+' : '-') + ')</text>';
    /* Стеклянный баллон */
    svg += '<ellipse cx="' + ((cx + ax + 14) / 2) + '" cy="' + cyy + '" rx="160" ry="70" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4 3"/>';
    /* Электроны летят */
    if (this.flying()){
      for (let i = 0; i < 5; i++){
        const t = ((this.phase * 0.7) + i * 0.2) % 1;
        const ex = cx + 14 + t * (ax - cx - 14);
        const ey = cyy + Math.sin(t * 6 + i) * 8;
        svg += '<circle cx="' + ex.toFixed(1) + '" cy="' + ey.toFixed(1) + '" r="4" fill="#16a34a" stroke="#065f46" stroke-width="1"/>';
        svg += '<text x="' + ex.toFixed(1) + '" y="' + (ey + 3).toFixed(1) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="8" fill="#fff">e</text>';
      }
    }
    /* Цепь — снизу */
    svg += '<line x1="' + (cx + 7) + '" y1="' + (cyy + 40) + '" x2="' + (cx + 7) + '" y2="' + (H - 30) + '" stroke="#0f172a" stroke-width="1.6"/>';
    svg += '<line x1="' + (cx + 7) + '" y1="' + (H - 30) + '" x2="' + (ax + 7) + '" y2="' + (H - 30) + '" stroke="#0f172a" stroke-width="1.6"/>';
    svg += '<line x1="' + (ax + 7) + '" y1="' + (cyy + 40) + '" x2="' + (ax + 7) + '" y2="' + (H - 30) + '" stroke="#0f172a" stroke-width="1.6"/>';
    /* Амперметр */
    const amx = (cx + ax) / 2 + 7, amy = H - 30;
    svg += '<circle cx="' + amx + '" cy="' + amy + '" r="14" fill="#fff" stroke="#0f172a" stroke-width="1.6"/>';
    svg += '<text x="' + amx + '" y="' + (amy + 4) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">A</text>';
    /* Стрелка тока */
    if (this.flying()){
      svg += '<text x="' + (amx - 28) + '" y="' + (amy - 18) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#16a34a" font-weight="700">I &gt; 0</text>';
    } else {
      svg += '<text x="' + (amx - 28) + '" y="' + (amy - 18) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#dc2626" font-weight="700">I = 0</text>';
    }
    /* Подписи */
    const h = 6.63e-34, e_ch = 1.6e-19;
    const Em = this.ekMax();
    const Aevh = h * this.nu0 / e_ch;
    svg += '<text x="' + (W - 12) + '" y="22" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a" font-weight="700">hν = A + Eк</text>';
    svg += '<text x="' + (W - 12) + '" y="40" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">ν = ' + (this.nu / 1e14).toFixed(2) + '·10¹⁴ Гц</text>';
    svg += '<text x="' + (W - 12) + '" y="56" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">ν₀ = ' + (this.nu0 / 1e14).toFixed(2) + '·10¹⁴ Гц</text>';
    svg += '<text x="' + (W - 12) + '" y="72" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">A = ' + Aevh.toFixed(2) + ' эВ</text>';
    svg += '<text x="' + (W - 12) + '" y="88" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">Eк,max = ' + (Em/e_ch).toFixed(2) + ' эВ</text>';
    svg += '<text x="' + (W - 12) + '" y="104" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">U = ' + this.U.toFixed(2) + ' В</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
  _render(){ this.render(); }
}
P.PhotoeffectLab = PhotoeffectLab;

/* ============================================================ */
/*  PlanckLinear — график Eк,max от ν                            */
/* ============================================================ */

class PlanckLinear {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 560;
    this.H = opts.height || 280;
    this.nu0 = opts.nu0 !== undefined ? opts.nu0 : 5e14;
    this.nu = opts.nu !== undefined ? opts.nu : 8e14;
    this.paused = true;
    this.render();
  }
  setNu0(v){ this.nu0 = v; this.render(); }
  setNu(v){ this.nu = v; this.render(); }
  update(){}
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const pad = 50;
    const left = pad, right = W - pad - 80, top = 30, bot = H - 50;
    const numax = 12e14;
    const h = 6.63e-34, e_ch = 1.6e-19;
    const eMaxEV = h * (numax - this.nu0) / e_ch;
    let svg = util.svgFrame(W, H, {bg:'#fdf2f8'});
    /* Сетка */
    svg += '<g stroke="#fbcfe8" stroke-width="0.8">';
    for (let i = 0; i <= 12; i++){
      const x = left + i * (right - left) / 12;
      svg += '<line x1="' + x + '" y1="' + top + '" x2="' + x + '" y2="' + bot + '"/>';
    }
    for (let i = 0; i <= 6; i++){
      const y = top + i * (bot - top) / 6;
      svg += '<line x1="' + left + '" y1="' + y + '" x2="' + right + '" y2="' + y + '"/>';
    }
    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="' + ((left+right)/2) + '" y="' + (bot + 30) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569">ν (·10¹⁴ Гц)</text>';
    svg += '<text x="' + (left - 30) + '" y="' + ((top+bot)/2) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" transform="rotate(-90 ' + (left - 30) + ' ' + ((top+bot)/2) + ')">Eк,max (эВ)</text>';
    /* Прямая Eк = h(ν - ν₀) */
    const nu0x = left + (this.nu0 / numax) * (right - left);
    let pts = '';
    for (let i = 0; i <= 50; i++){
      const v = this.nu0 + i * (numax - this.nu0) / 50;
      const eV = h * (v - this.nu0) / e_ch;
      const x = left + (v / numax) * (right - left);
      const y = bot - (eV / Math.max(0.1, eMaxEV)) * (bot - top);
      pts += x.toFixed(1) + ',' + y.toFixed(1) + ' ';
    }
    svg += '<polyline points="' + pts + '" fill="none" stroke="#ec4899" stroke-width="2.4"/>';
    /* До ν₀ — горизонтальная линия Eк = 0 */
    svg += '<line x1="' + left + '" y1="' + bot + '" x2="' + nu0x + '" y2="' + bot + '" stroke="#ec4899" stroke-width="2.4" stroke-dasharray="4 3"/>';
    /* Отметка ν₀ */
    svg += '<line x1="' + nu0x + '" y1="' + bot + '" x2="' + nu0x + '" y2="' + (bot + 6) + '" stroke="#dc2626" stroke-width="1.4"/>';
    svg += '<text x="' + nu0x + '" y="' + (bot + 18) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="10" fill="#dc2626" font-weight="700">ν₀</text>';
    /* Текущая точка ν */
    const cx2 = left + (this.nu / numax) * (right - left);
    const eVcur = Math.max(0, h * (this.nu - this.nu0) / e_ch);
    const cy2 = bot - (eVcur / Math.max(0.1, eMaxEV)) * (bot - top);
    svg += '<line x1="' + cx2.toFixed(1) + '" y1="' + bot + '" x2="' + cx2.toFixed(1) + '" y2="' + cy2.toFixed(1) + '" stroke="#0f172a" stroke-width="1" stroke-dasharray="3 3"/>';
    svg += '<circle cx="' + cx2.toFixed(1) + '" cy="' + cy2.toFixed(1) + '" r="5" fill="#0f172a"/>';
    /* Метки на осях X */
    for (let v = 0; v <= 12; v += 2){
      const x = left + (v / 12) * (right - left);
      svg += '<text x="' + x + '" y="' + (bot + 14) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="9" fill="#64748b">' + v + '</text>';
    }
    /* Подпись */
    svg += '<text x="' + (W/2) + '" y="20" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">Eк,max = h(ν − ν₀) — угловой коэф. = h</text>';
    /* Панель */
    const px = right + 12;
    svg += '<text x="' + px + '" y="' + (top + 14) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#ec4899" font-weight="700">ν = ' + (this.nu/1e14).toFixed(1) + '·10¹⁴</text>';
    svg += '<text x="' + px + '" y="' + (top + 32) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">ν₀ = ' + (this.nu0/1e14).toFixed(1) + '·10¹⁴</text>';
    svg += '<text x="' + px + '" y="' + (top + 50) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#0f172a">Eк = ' + eVcur.toFixed(2) + ' эВ</text>';
    svg += '<text x="' + px + '" y="' + (top + 68) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">A = ' + (h * this.nu0 / e_ch).toFixed(2) + ' эВ</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
}
P.PlanckLinear = PlanckLinear;

/* ============================================================ */
/*  BohrAtom — атом водорода с орбитами и переходом              */
/* ============================================================ */

class BohrAtom {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 540;
    this.H = opts.height || 380;
    this.n_from = opts.n_from !== undefined ? opts.n_from : 3;
    this.n_to = opts.n_to !== undefined ? opts.n_to : 2;
    this.phase = 0;
    this.transitioning = false;
    this.t_trans = 0;
    this.paused = false;
    util.subscribe(this);
    util.observe(this);
    this._render();
  }
  setFrom(n){ this.n_from = n; this.startTransition(); }
  setTo(n){ this.n_to = n; this.startTransition(); }
  startTransition(){ this.transitioning = true; this.t_trans = 0; }
  update(dt){
    this.phase += dt * 2;
    if (this.transitioning){
      this.t_trans += dt;
      if (this.t_trans > 1.5){ this.transitioning = false; this.t_trans = 0; }
    }
  }
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const cx = W / 2, cy = H / 2;
    let svg = util.svgFrame(W, H, {bg:'#0f172a'});
    /* Ядро */
    svg += '<circle cx="' + cx + '" cy="' + cy + '" r="14" fill="#dc2626" stroke="#fff" stroke-width="2"/>';
    svg += '<text x="' + cx + '" y="' + (cy + 5) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" fill="#fff" font-weight="700">+</text>';
    /* Орбиты n = 1..5 */
    const radii = [30, 55, 85, 120, 160];
    for (let n = 0; n < 5; n++){
      const r = radii[n];
      const active = (n + 1 === this.n_from || n + 1 === this.n_to);
      svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="none" stroke="' + (active ? '#fcd34d' : '#475569') + '" stroke-width="' + (active ? 1.5 : 0.8) + '" opacity="' + (active ? 0.9 : 0.5) + '"/>';
      svg += '<text x="' + (cx + r + 4) + '" y="' + (cy + 4) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#94a3b8">n=' + (n + 1) + '</text>';
    }
    /* Электрон на текущей орбите */
    let curN;
    if (this.transitioning){
      const t = Math.min(1, this.t_trans / 0.8);
      const r1 = radii[this.n_from - 1] || 30;
      const r2 = radii[this.n_to - 1] || 30;
      const r = r1 + (r2 - r1) * t;
      const a = this.phase;
      const ex = cx + r * Math.cos(a), ey = cy + r * Math.sin(a);
      svg += '<circle cx="' + ex.toFixed(1) + '" cy="' + ey.toFixed(1) + '" r="6" fill="#06b6d4" stroke="#fff" stroke-width="1.4"/>';
      curN = this.n_to;
      /* Фотон при переходе на нижний уровень */
      if (this.n_from > this.n_to && t > 0.4){
        const pt = (t - 0.4) / 0.6;
        const fx = ex + 80 * pt;
        const fy = ey - 40 * pt;
        svg += '<g><path d="M ' + ex.toFixed(1) + ' ' + ey.toFixed(1) + ' Q ' + ((ex + fx) / 2).toFixed(1) + ' ' + ((ey + fy) / 2 - 10).toFixed(1) + ' ' + fx.toFixed(1) + ' ' + fy.toFixed(1) + '" stroke="#facc15" stroke-width="2" fill="none" opacity="' + (1 - pt) + '"/>';
        svg += '<text x="' + (fx + 12).toFixed(1) + '" y="' + fy.toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#facc15" font-weight="700" opacity="' + (1 - pt) + '">hν</text></g>';
      }
    } else {
      const r = radii[this.n_to - 1] || 30;
      const a = this.phase;
      const ex = cx + r * Math.cos(a), ey = cy + r * Math.sin(a);
      svg += '<circle cx="' + ex.toFixed(1) + '" cy="' + ey.toFixed(1) + '" r="6" fill="#06b6d4" stroke="#fff" stroke-width="1.4"/>';
      curN = this.n_to;
    }
    /* Подписи */
    const En = -13.6 / (curN * curN);
    svg += '<text x="14" y="22" font-family="JetBrains Mono,monospace" font-size="11" fill="#cbd5e1" font-weight="700">Боровская модель атома H</text>';
    svg += '<text x="14" y="38" font-family="JetBrains Mono,monospace" font-size="10" fill="#94a3b8">n = ' + curN + '  ·  E = -13,6/n² = ' + En.toFixed(2) + ' эВ</text>';
    if (this.n_from !== this.n_to){
      const E_f = -13.6 / (this.n_from * this.n_from);
      const E_t = -13.6 / (this.n_to * this.n_to);
      const dE = Math.abs(E_t - E_f);
      svg += '<text x="14" y="' + (H - 26) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#fcd34d" font-weight="700">переход ' + this.n_from + ' → ' + this.n_to + '</text>';
      svg += '<text x="14" y="' + (H - 12) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#cbd5e1">hν = |E' + this.n_from + ' − E' + this.n_to + '| = ' + dE.toFixed(2) + ' эВ</text>';
    }
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
  _render(){ this.render(); }
}
P.BohrAtom = BohrAtom;

/* ============================================================ */
/*  EnergyLevels — диаграмма E_n + переход                       */
/* ============================================================ */

class EnergyLevels {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 540;
    this.H = opts.height || 360;
    this.n_from = opts.n_from !== undefined ? opts.n_from : 4;
    this.n_to = opts.n_to !== undefined ? opts.n_to : 2;
    this.paused = true;
    this.render();
  }
  setFrom(n){ this.n_from = n; this.render(); }
  setTo(n){ this.n_to = n; this.render(); }
  update(){}
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const pad = 40, leftLine = 200, rightLine = W - 100;
    let svg = util.svgFrame(W, H, {bg:'#fef3c7'});
    /* Уровни n=1..6 + ионизация */
    const nMax = 6;
    /* y(n) — нелинейное расположение, E пропорционально -1/n² */
    function E(n){ return -13.6 / (n * n); }
    function y(En){
      const minE = -13.6, maxE = 0;
      return pad + (maxE - En) / (maxE - minE) * (H - 2 * pad);
    }
    /* Линия ионизации (E=0) */
    const y0 = y(0);
    svg += '<line x1="' + leftLine + '" y1="' + y0 + '" x2="' + rightLine + '" y2="' + y0 + '" stroke="#dc2626" stroke-width="1.6" stroke-dasharray="6 4"/>';
    svg += '<text x="' + (rightLine + 6) + '" y="' + (y0 + 4) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">E = 0 (ионизация)</text>';
    /* Уровни */
    for (let n = 1; n <= nMax; n++){
      const En = E(n);
      const yL = y(En);
      const active = (n === this.n_from || n === this.n_to);
      svg += '<line x1="' + leftLine + '" y1="' + yL.toFixed(1) + '" x2="' + rightLine + '" y2="' + yL.toFixed(1) + '" stroke="' + (active ? '#dc2626' : '#475569') + '" stroke-width="' + (active ? 2 : 1.2) + '"/>';
      svg += '<text x="' + (leftLine - 8) + '" y="' + (yL + 4).toFixed(1) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" fill="' + (active ? '#dc2626' : '#0f172a') + '" font-weight="' + (active ? '700' : '600') + '">n=' + n + '</text>';
      svg += '<text x="' + (rightLine + 6) + '" y="' + (yL + 4).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">' + En.toFixed(2) + ' эВ</text>';
    }
    /* Переход — стрелка */
    if (this.n_from !== this.n_to){
      const E1 = E(this.n_from), E2 = E(this.n_to);
      const y1 = y(E1), y2 = y(E2);
      const xT = (leftLine + rightLine) / 2;
      const color = E1 > E2 ? '#dc2626' : '#16a34a';
      svg += '<line x1="' + xT + '" y1="' + y1.toFixed(1) + '" x2="' + xT + '" y2="' + y2.toFixed(1) + '" stroke="' + color + '" stroke-width="2.6"/>';
      const dir = y2 < y1 ? -1 : 1;
      svg += '<polygon points="' + xT + ',' + y2.toFixed(1) + ' ' + (xT - 6) + ',' + (y2 + 10 * dir).toFixed(1) + ' ' + (xT + 6) + ',' + (y2 + 10 * dir).toFixed(1) + '" fill="' + color + '"/>';
      const dE = Math.abs(E1 - E2);
      svg += '<text x="' + (xT + 12) + '" y="' + ((y1 + y2) / 2).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="12" fill="' + color + '" font-weight="700">hν = ' + dE.toFixed(2) + ' эВ</text>';
      const lam = 1240 / dE;
      svg += '<text x="' + (xT + 12) + '" y="' + ((y1 + y2) / 2 + 16).toFixed(1) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="' + color + '">λ ≈ ' + lam.toFixed(0) + ' нм</text>';
    }
    /* Серии */
    svg += '<text x="14" y="' + (y(E(1)) + 4) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#9d174d" font-weight="700">Лайман →</text>';
    svg += '<text x="14" y="' + (y(E(2)) + 4) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#9d174d" font-weight="700">Бальмер →</text>';
    svg += '<text x="14" y="' + (y(E(3)) + 4) + '" font-family="JetBrains Mono,monospace" font-size="10" fill="#9d174d" font-weight="700">Пашен →</text>';
    svg += '<text x="' + (W/2) + '" y="22" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="11" fill="#475569" font-weight="700">E_n = -13,6/n² (атом водорода)</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
}
P.EnergyLevels = EnergyLevels;

/* ============================================================ */
/*  RadioactiveDecay — N(t) = N0 * 2^(-t/T)                       */
/* ============================================================ */

class RadioactiveDecay {
  constructor(container, opts){
    opts = opts || {};
    this.el = (typeof container === 'string') ? document.querySelector(container) : container;
    this.W = opts.width || 600;
    this.H = opts.height || 300;
    this.T = opts.T !== undefined ? opts.T : 2.0;     /* период полураспада */
    this.tMax = opts.tMax !== undefined ? opts.tMax : 10;
    this.t = 0;
    this.paused = false;
    util.subscribe(this);
    util.observe(this);
    this._render();
  }
  setT(v){ this.T = Math.max(0.2, v); this.t = 0; }
  reset(){ this.t = 0; }
  update(dt){ this.t = (this.t + dt * 0.5) % this.tMax; }
  render(){
    if (!this.el) return;
    const W = this.W, H = this.H;
    const pad = 40, left = pad, right = W - pad - 100, top = 30, bot = H - 40;
    let svg = util.svgFrame(W, H, {bg:'#fef9c3'});
    /* Сетка */
    svg += '<g stroke="#facc15" stroke-width="0.6" opacity="0.5">';
    for (let i = 0; i <= 10; i++){
      const x = left + i * (right - left) / 10;
      svg += '<line x1="' + x + '" y1="' + top + '" x2="' + x + '" y2="' + bot + '"/>';
    }
    for (let i = 0; i <= 4; i++){
      const y = top + i * (bot - top) / 4;
      svg += '<line x1="' + left + '" y1="' + y + '" x2="' + right + '" y2="' + y + '"/>';
    }
    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"/>';
    /* Кривая */
    let pts = '';
    for (let i = 0; i <= 100; i++){
      const tau = i * this.tMax / 100;
      const N = Math.pow(2, -tau / this.T);
      const x = left + tau / this.tMax * (right - left);
      const y = bot - N * (bot - top);
      pts += x.toFixed(1) + ',' + y.toFixed(1) + ' ';
    }
    svg += '<polyline points="' + pts + '" fill="none" stroke="#dc2626" stroke-width="2.6"/>';
    /* Точки полураспадов: 1T, 2T, 3T... */
    for (let k = 1; k * this.T < this.tMax; k++){
      const tau = k * this.T;
      const N = Math.pow(2, -k);
      const x = left + tau / this.tMax * (right - left);
      const y = bot - N * (bot - top);
      svg += '<circle cx="' + x.toFixed(1) + '" cy="' + y.toFixed(1) + '" r="3.5" fill="#0f172a"/>';
      svg += '<line x1="' + x.toFixed(1) + '" y1="' + bot + '" x2="' + x.toFixed(1) + '" y2="' + y.toFixed(1) + '" stroke="#0f172a" stroke-width="0.8" stroke-dasharray="2 2"/>';
      svg += '<text x="' + x.toFixed(1) + '" y="' + (bot + 12) + '" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="9" fill="#475569">' + k + 'T</text>';
    }
    /* Текущая точка t */
    const Nt = Math.pow(2, -this.t / this.T);
    const cx = left + this.t / this.tMax * (right - left);
    const cy = bot - Nt * (bot - top);
    svg += '<circle cx="' + cx.toFixed(1) + '" cy="' + cy.toFixed(1) + '" r="6" fill="#facc15" stroke="#92400e" stroke-width="1.8"/>';
    /* Подписи */
    svg += '<text x="' + (left - 4) + '" y="' + top + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">N₀</text>';
    svg += '<text x="' + (left - 4) + '" y="' + (top + (bot - top) / 2 + 4) + '" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="10" fill="#475569">N₀/2</text>';
    /* Панель справа */
    const px = right + 12;
    svg += '<text x="' + px + '" y="' + (top + 14) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#dc2626" font-weight="700">N(t) = N₀ · 2^(-t/T)</text>';
    svg += '<text x="' + px + '" y="' + (top + 34) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a">T = ' + this.T.toFixed(2) + '</text>';
    svg += '<text x="' + px + '" y="' + (top + 50) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#0f172a">t = ' + this.t.toFixed(2) + '</text>';
    svg += '<text x="' + px + '" y="' + (top + 66) + '" font-family="JetBrains Mono,monospace" font-size="11" fill="#92400e" font-weight="700">N/N₀ = ' + Nt.toFixed(3) + '</text>';
    svg += '</svg>';
    this.el.innerHTML = svg;
  }
  _render(){ this.render(); }
}
P.RadioactiveDecay = RadioactiveDecay;

})();
