'use strict';

/* ═══════════════════════════════════════════════
   GraphSim — interactive function plotter
   Usage:
     const sim = new GraphSim(canvasElement);
     sim.setFn(0, 'sin(x)', '#9B5DE5');
     sim.onHover = (mx, yVals) => { ... };
   ═══════════════════════════════════════════════ */

class GraphSim {
  constructor(canvas) {
    this.c   = canvas;
    this.ctx = canvas.getContext('2d');
    this.ox  = 0;    // viewport centre x (math units)
    this.oy  = 0;    // viewport centre y (math units)
    this.scl = 50;   // px per unit
    this.fns = [];   // [{ color, fn } | null]
    this.hx  = null; // hovered x (math) or null
    this._dg = null; // drag state
    this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)

    this._bind();
    new ResizeObserver(() => { this.fit(); this.draw(); })
      .observe(canvas.parentElement);
  }

  /* ── public ────────────────────────────────── */

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const r = this.c.parentElement.getBoundingClientRect();
    const w = r.width || 600, h = r.height || 400;
    this.c.width  = w * dpr;
    this.c.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this._cw = w; this._ch = h;
  }

  /** idx 0-2, expr string, color hex. Returns error string or null. */
  setFn(idx, expr, color) {
    if (!expr || !expr.trim()) {
      this.fns[idx] = null;
      this.draw();
      return null;
    }
    try {
      const fn = this._compile(expr);
      fn(0); fn(1); fn(-1); fn(Math.PI); // smoke-test
      const wasNull = !this.fns[idx];
      this.fns[idx] = { color, fn };
      this.draw();
      if (wasNull && window.LabFX) LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
      return null;
    } catch {
      this.fns[idx] = null;
      this.draw();
      return 'Синтаксическая ошибка';
    }
  }

  resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
  zoomIn()    { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
  zoomOut()   { this.scl = Math.max(4,   this.scl / 1.3); this.draw(); }

  /* ── formula compiler (CSP-safe: no eval / new Function) ── */

  _compile(raw) {
    const tokens   = this._tokenize(raw.trim());
    const expanded = this._insertImplicit(tokens);
    return this._parseExpr(expanded);
  }

  _tokenize(src) {
    const out = [];
    let i = 0;
    while (i < src.length) {
      const ch = src[i];
      if (/\s/.test(ch)) { i++; continue; }

      /* number */
      if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(src[i + 1] || ''))) {
        let j = i;
        while (j < src.length && /[0-9]/.test(src[j])) j++;
        if (j < src.length && src[j] === '.') {
          j++;
          while (j < src.length && /[0-9]/.test(src[j])) j++;
        }
        if (j < src.length && /[eE]/.test(src[j])) {
          j++;
          if (j < src.length && /[+\-]/.test(src[j])) j++;
          while (j < src.length && /[0-9]/.test(src[j])) j++;
        }
        out.push({ type: 'num', val: parseFloat(src.slice(i, j)) });
        i = j;
        continue;
      }

      /* identifier */
      if (/[a-zA-Z_]/.test(ch)) {
        let j = i;
        while (j < src.length && /[a-zA-Z_0-9]/.test(src[j])) j++;
        out.push({ type: 'id', val: src.slice(i, j) });
        i = j;
        continue;
      }

      /* operator / bracket */
      if ('+-*/^()'.includes(ch)) {
        out.push({ type: 'op', val: ch });
        i++;
        continue;
      }

      throw new Error('Unknown character: ' + ch);
    }
    return out;
  }

  /* Insert implicit '*' where adjacent tokens imply multiplication.
     Covers: 2x <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2*x,  2( <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2*(,  )( <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> )*(,  )x <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> )*x            */
  _insertImplicit(tokens) {
    const FUNS = new Set([
      'sin','cos','tan','tg','ctg',
      'asin','acos','atan','arcsin','arccos','arctan','arctg',
      'sqrt','abs','exp','ln','log','log2','log10',
      'ceil','floor','round','sign',
    ]);
    const out = [];
    for (let i = 0; i < tokens.length; i++) {
      out.push(tokens[i]);
      const cur = tokens[i], nxt = tokens[i + 1];
      if (!nxt) continue;
      const curEnds   = cur.type === 'num' ||
                        (cur.type === 'id' && !FUNS.has(cur.val)) ||
                        (cur.type === 'op' && cur.val === ')');
      const nxtStarts = nxt.type === 'num' ||
                        nxt.type === 'id'  ||
                        (nxt.type === 'op' && nxt.val === '(');
      if (curEnds && nxtStarts) out.push({ type: 'op', val: '*' });
    }
    return out;
  }

  /* Recursive-descent parser <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> returns a closure  x => number */
  _parseExpr(tokens) {
    let pos = 0;
    const peek = () => tokens[pos];
    const next = () => tokens[pos++];
    const eat  = v => {
      if (!peek() || peek().val !== v) throw new Error('Expected ' + v);
      pos++;
    };

    /* All supported functions including Russian aliases */
    const FN = {
      sin: Math.sin,  cos: Math.cos,  tan: Math.tan,  tg: Math.tan,
      asin: Math.asin, acos: Math.acos, atan: Math.atan,
      arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan,
      sqrt: Math.sqrt, abs: Math.abs,  exp: Math.exp,
      ln: Math.log,   log: Math.log10, log2: Math.log2, log10: Math.log10,
      ceil: Math.ceil, floor: Math.floor, round: Math.round, sign: Math.sign,
      ctg: t => 1 / Math.tan(t),
    };

    /* additive: left-associative +/- */
    const addSub = () => {
      let l = mulDiv();
      while (peek() && (peek().val === '+' || peek().val === '-')) {
        const op = next().val;
        const r  = mulDiv();
        const ll = l;
        l = op === '+' ? x => ll(x) + r(x) : x => ll(x) - r(x);
      }
      return l;
    };

    /* multiplicative: left-associative */
    const mulDiv = () => {
      let l = power();
      while (peek() && (peek().val === '*' || peek().val === '/')) {
        const op = next().val;
        const r  = power();
        const ll = l;
        l = op === '*' ? x => ll(x) * r(x) : x => ll(x) / r(x);
      }
      return l;
    };

    /* power: right-associative ^ */
    const power = () => {
      const base = unary();
      if (peek() && peek().val === '^') {
        next();
        const exp = power(); // right-recursive <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> right-associative
        return x => Math.pow(base(x), exp(x));
      }
      return base;
    };

    /* unary minus / plus */
    const unary = () => {
      if (peek()?.val === '-') { next(); const v = unary(); return x => -v(x); }
      if (peek()?.val === '+') { next(); return unary(); }
      return primary();
    };

    /* primary: number | variable | constant | fn(…) | (…) */
    const primary = () => {
      const t = peek();
      if (!t) throw new Error('Unexpected end of expression');

      if (t.type === 'num') {
        next();
        const v = t.val;
        return () => v;
      }

      if (t.type === 'id') {
        next();
        if (t.val === 'x')                    return x => x;
        if (t.val === 'pi' || t.val === 'PI') return () => Math.PI;
        if (t.val === 'e')                    return () => Math.E;
        if (FN[t.val]) {
          eat('(');
          const arg = addSub();
          eat(')');
          const f = FN[t.val];
          return x => f(arg(x));
        }
        throw new Error('Unknown identifier: ' + t.val);
      }

      if (t.type === 'op' && t.val === '(') {
        next();
        const v = addSub();
        eat(')');
        return v;
      }

      throw new Error('Unexpected token: ' + t.val);
    };

    const fn = addSub();
    if (pos !== tokens.length) throw new Error('Unexpected tokens after expression');
    return fn;
  }

  /* ── coordinate transforms ─────────────────── */

  _toPx(mx, my) {
    const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2;
    return [cx + (mx - this.ox) * this.scl,
            cy - (my - this.oy) * this.scl];
  }

  _toMx(px, py) {
    const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2;
    return [(px - cx) / this.scl + this.ox,
            -(py - cy) / this.scl + this.oy];
  }

  /* ── main render ───────────────────────────── */

  draw() {
    const c = this.ctx, W = this._cw || this.c.width, H = this._ch || this.c.height;
    if (!W || !H) return;

    c.fillStyle = '#0D0D1A';
    c.fillRect(0, 0, W, H);

    this._drawGrid(c, W, H);
    this._drawAxes(c, W, H);
    for (const f of this.fns) if (f) this._drawCurve(c, W, H, f);
    if (this.hx !== null) this._drawHover(c, W, H);
  }

  /* ── grid ──────────────────────────────────── */

  _niceStep() {
    const raw = (this._cw || this.c.width) / this.scl / 8;
    const p = Math.pow(10, Math.floor(Math.log10(raw)));
    for (const n of [1, 2, 5, 10]) if (n * p >= raw) return n * p;
    return p;
  }

  _drawGrid(c, W, H) {
    const step = this._niceStep();
    const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
    const [, y0] = this._toMx(0, H), [, y1] = this._toMx(0, 0);
    const gx = Math.floor(x0 / step) * step;
    const gy = Math.floor(y0 / step) * step;

    c.strokeStyle = 'rgba(255,255,255,0.065)';
    c.lineWidth = 1;
    for (let x = gx; x <= x1 + step; x += step) {
      const [px] = this._toPx(x, 0);
      c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
    }
    for (let y = gy; y <= y1 + step; y += step) {
      const [, py] = this._toPx(0, y);
      c.beginPath(); c.moveTo(0, py); c.lineTo(W, py); c.stroke();
    }

    /* number labels */
    c.font = '11px Manrope, system-ui, sans-serif';
    c.fillStyle = 'rgba(255,255,255,0.3)';
    const [axX, axY] = this._toPx(0, 0);
    const lblY = Math.max(4,  Math.min(H - 18, axY + 5));
    const lblX = Math.max(28, Math.min(W - 6,  axX - 5));

    c.textAlign = 'center'; c.textBaseline = 'top';
    for (let x = gx; x <= x1; x += step) {
      if (Math.abs(x) < step * 0.01) continue;
      const [px] = this._toPx(x, 0);
      if (px < 18 || px > W - 18) continue;
      c.fillText(this._fmtN(x, step), px, lblY);
    }
    c.textAlign = 'right'; c.textBaseline = 'middle';
    for (let y = gy; y <= y1; y += step) {
      if (Math.abs(y) < step * 0.01) continue;
      const [, py] = this._toPx(0, y);
      if (py < 12 || py > H - 12) continue;
      c.fillText(this._fmtN(y, step), lblX, py);
    }
  }

  _fmtN(n, step) {
    if (n === 0) return '0';
    if (step >= 1 && Number.isInteger(n)) return String(n);
    if (step < 0.001) return n.toExponential(1);
    const dec = Math.max(0, -Math.floor(Math.log10(step)));
    return n.toFixed(dec);
  }

  /* ── axes ──────────────────────────────────── */

  _drawAxes(c, W, H) {
    const [ax, ay] = this._toPx(0, 0);
    c.strokeStyle = 'rgba(255,255,255,0.4)';
    c.lineWidth = 1.5;
    c.beginPath(); c.moveTo(0, ay);  c.lineTo(W - 10, ay); c.stroke();
    c.beginPath(); c.moveTo(ax, H); c.lineTo(ax, 8);      c.stroke();

    c.fillStyle = 'rgba(255,255,255,0.4)';
    this._arrowHead(c, W - 8, ay, 0);
    this._arrowHead(c, ax, 6, -Math.PI / 2);

    c.fillStyle = 'rgba(255,255,255,0.55)';
    c.font = 'bold 12px Manrope, sans-serif';
    c.textBaseline = 'middle'; c.textAlign = 'left';
    c.fillText('x', W - 10, ay - 13);
    c.textBaseline = 'top'; c.textAlign = 'left';
    c.fillText('y', ax + 7, 4);
  }

  _arrowHead(c, x, y, angle) {
    const s = 5;
    c.save(); c.translate(x, y); c.rotate(angle);
    c.beginPath();
    c.moveTo(0, 0); c.lineTo(-s * 1.6, -s * 0.6); c.lineTo(-s * 1.6, s * 0.6);
    c.closePath(); c.fill();
    c.restore();
  }

  /* ── curve ─────────────────────────────────── */

  _drawCurve(c, W, H, { fn, color }) {
    const steps  = Math.min(W * 2, 2000);
    const [x0]  = this._toMx(0, 0), [x1] = this._toMx(W, 0);
    const dx     = (x1 - x0) / steps;
    const maxJmp = (H / this.scl) * 2; // discontinuity threshold (math units)

    const drawPath = () => {
      c.strokeStyle = color;
      c.lineWidth = 2.5;
      c.lineJoin  = 'round';
      c.beginPath();

      let pen = false, pyPrev = null;
      for (let i = 0; i <= steps; i++) {
        const mx = x0 + i * dx;
        let my;
        try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
        if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }

        // discontinuity guard
        if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
          pen = false;
        }

        const [px, py] = this._toPx(mx, my);
        pen ? c.lineTo(px, py) : c.moveTo(px, py);
        pen = true; pyPrev = my;
      }
      c.stroke();
    };

    if (window.LabFX) {
      LabFX.glow.drawGlow(c, drawPath, { color, intensity: 4 });
    } else {
      drawPath();
    }
  }

  /* ── hover crosshair ───────────────────────── */

  _drawHover(c, W, H) {
    const [px] = this._toPx(this.hx, 0);

    c.strokeStyle = 'rgba(255,255,255,0.15)';
    c.lineWidth = 1;
    c.setLineDash([5, 5]);
    c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
    c.setLineDash([]);

    for (const f of this.fns) {
      if (!f) continue;
      let my;
      try { my = f.fn(this.hx); } catch { continue; }
      if (!isFinite(my) || isNaN(my)) continue;

      const [, py] = this._toPx(this.hx, my);
      if (py < -20 || py > H + 20) continue;

      c.fillStyle = f.color;
      c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
      c.strokeStyle = 'rgba(255,255,255,0.8)';
      c.lineWidth = 1.5; c.stroke();
    }
  }

  /* ── events ─────────────────────────────────── */

  _bind() {
    const cv = this.c;

    /* wheel zoom — zoom toward cursor */
    cv.addEventListener('wheel', e => {
      e.preventDefault();
      const [mx, my] = this._toMx(e.offsetX, e.offsetY);
      this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
      const [nx, ny] = this._toMx(e.offsetX, e.offsetY);
      this.ox -= nx - mx; this.oy -= ny - my;
      this.draw();
    }, { passive: false });

    /* mouse drag */
    cv.addEventListener('mousedown', e => {
      this._dg = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
      cv.style.cursor = 'grabbing';
    });
    window.addEventListener('mousemove', e => {
      if (this._dg) {
        this.ox = this._dg.ox - (e.clientX - this._dg.x) / this.scl;
        this.oy = this._dg.oy + (e.clientY - this._dg.y) / this.scl;
        this.draw();
      } else {
        const r = cv.getBoundingClientRect();
        const lx = e.clientX - r.left, ly = e.clientY - r.top;
        if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
          this.hx = this._toMx(lx, ly)[0];
          this.draw();
          this._emitHover();
        }
      }
    });
    window.addEventListener('mouseup', () => {
      this._dg = null;
      cv.style.cursor = 'crosshair';
    });
    cv.addEventListener('mouseleave', () => {
      this.hx = null; this.draw();
      if (this.onHover) this.onHover(null, null);
    });
    cv.style.cursor = 'crosshair';

    /* touch drag */
    let t0 = null;
    cv.addEventListener('touchstart', e => {
      if (e.touches.length === 1)
        t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
    }, { passive: true });
    cv.addEventListener('touchmove', e => {
      e.preventDefault();
      if (e.touches.length === 1 && t0) {
        this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
        this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
        this.draw();
      }
    }, { passive: false });
    cv.addEventListener('touchend', () => { t0 = null; });
  }

  _emitHover() {
    if (!this.onHover) return;
    const vals = this.fns.map(f => {
      if (!f) return null;
      try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
    });
    this.onHover(this.hx, vals);
  }
}

/* ─── lab UI init ─────────────────────────────────── */
  function _openGraph() {
    document.getElementById('sim-topbar-title').textContent = 'График функции';
    _simShow('sim-graph');
    _simShow('ctrl-graph');

    _registerSimState('graph',
      () => ({
        fns: [0,1,2].map(i => ({ expr: document.getElementById(`fn${i}`)?.value || '', color: FN_COLORS[i] }))
      }),
      (st) => {
        if (!Array.isArray(st.fns)) return;
        st.fns.forEach((fn, i) => {
          const el = document.getElementById(`fn${i}`);
          if (el) { el.value = fn.expr; }
          if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
        });
      }
    );
    if (_embedMode) _startStateEmit('graph');

    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!gSim) {
        gSim = new GraphSim(document.getElementById('graph-canvas'));
        gSim.onHover = updateInfoBar;
        if (!document.getElementById('fn0').value.trim()) {
          document.getElementById('fn0').value = 'sin(x)';
          renderPreview(0);
          gSim.fit();
          gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
          return;
        }
      }
      gSim.fit();
      gSim.draw();
    }));
  }

  /* ── projectile ── */

  function toLatex(expr) {
    if (!expr) return '';
    return expr
      // strip leading y= if typed
      .replace(/^\s*y\s*=\s*/i, '')
      // inverse trig (before sin/cos/tan)
      .replace(/\barcsin\b/g, '\\arcsin').replace(/\barccos\b/g, '\\arccos')
      .replace(/\b(arctan|arctg|atan|acos|asin)\b/g,  (_, w) =>
        w === 'asin' ? '\\arcsin' : w === 'acos' ? '\\arccos' : '\\arctan')
      // trig
      .replace(/\bctg\b/g, '\\cot').replace(/\btg\b/g, '\\tan')
      .replace(/\b(sin|cos|tan)\b/g, '\\$1')
      // log / exp
      .replace(/\bln\b/g, '\\ln').replace(/\blog2\b/g, '\\log_2')
      .replace(/\blog\b/g, '\\log').replace(/\bexp\b/g, '\\exp')
      // special functions: f(inner) <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> LaTeX form
      .replace(/\bsqrt\(([^()]*)\)/g,  '\\sqrt{$1}')
      .replace(/\babs\(([^()]*)\)/g,   '\\left|$1\\right|')
      .replace(/\bfloor\(([^()]*)\)/g, '\\lfloor $1 \\rfloor')
      .replace(/\bceil\(([^()]*)\)/g,  '\\lceil $1 \\rceil')
      .replace(/\b(round|sign)\b/g,    '\\operatorname{$1}')
      // constants
      .replace(/\bpi\b/gi, '\\pi')
      // power: wrap exponent in braces for multi-char
      .replace(/\^(-?\d{2,})/g, '^{$1}')
      // clean up multiplication
      .replace(/([0-9])\s*\*\s*([a-zA-Z\\])/g, '$1\\,$2')
      .replace(/\*/g, '\\cdot ');
  }

  function renderPreview(idx) {
    const inp  = document.getElementById('fn' + idx);
    const prev = document.getElementById('fn' + idx + '-prev');
    const raw  = inp?.value?.trim() || '';
    if (!raw || typeof katex === 'undefined') {
      prev.innerHTML = ''; prev.classList.remove('has-content'); return;
    }
    try {
      prev.innerHTML = katex.renderToString(toLatex(raw), {
        throwOnError: false, strict: false, displayMode: false,
      });
      prev.classList.add('has-content');
    } catch { prev.innerHTML = ''; prev.classList.remove('has-content'); }
  }

  /* debounced formula update */
  const _debounce = {};
  let _graphSoundTs = 0;
  function updateFn(idx) {
    clearTimeout(_debounce[idx]);
    renderPreview(idx); // instant preview
    const now = performance.now();
    if (window.LabFX && now - _graphSoundTs > 80) {
      _graphSoundTs = now;
      LabFX.sound.play('tick', { pitch: 1.0, volume: 0.1 });
    }
    _debounce[idx] = setTimeout(() => {
      if (!gSim) return;
      const raw = document.getElementById('fn' + idx).value;
      const val = raw.replace(/^\s*y\s*=\s*/i, '');
      const err = gSim.setFn(idx, val, FN_COLORS[idx]);
      const errEl = document.getElementById('fn' + idx + '-err');
      errEl.classList.toggle('show', !!err && !!val.trim());
    }, 350);
  }

  function applyPreset(expr) {
    for (let i = 0; i < 3; i++) {
      const inp = document.getElementById('fn' + i);
      if (!inp.value.trim()) {
        inp.value = expr; updateFn(i); inp.focus(); return;
      }
    }
    document.getElementById('fn0').value = expr; updateFn(0);
  }

  function clearAll() {
    for (let i = 0; i < 3; i++) {
      document.getElementById('fn' + i).value = '';
      document.getElementById('fn' + i + '-prev').innerHTML = '';
      document.getElementById('fn' + i + '-prev').classList.remove('has-content');
      document.getElementById('fn' + i + '-err').classList.remove('show');
      if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
    }
  }

  /* hover info bar */
  function fmtVal(v) {
    if (v === null || v === undefined) return '—';
    if (!isFinite(v)) return '∞';
    const abs = Math.abs(v);
    if (abs === 0) return '0';
    if (abs < 0.001 || abs >= 1e6) return v.toExponential(3);
    return parseFloat(v.toPrecision(6)).toString();
  }

  function updateInfoBar(mx, vals) {
    document.getElementById('info-x').textContent  = mx !== null ? fmtVal(mx) : '—';
    document.getElementById('info-y0').textContent = vals ? fmtVal(vals[0]) : '—';
    document.getElementById('info-y1').textContent = vals ? fmtVal(vals[1]) : '—';
    document.getElementById('info-y2').textContent = vals ? fmtVal(vals[2]) : '—';
  }

  /* ════════════════════════════════
     МОЛЕКУЛЯРНАЯ ФИЗИКА (unified: gas + brownian + states + diffusion)
  ════════════════════════════════ */

  let _molMode = 'gas'; // 'gas' | 'brownian' | 'states' | 'diffusion'

