'use strict';
/* ══════════════════════════════════════════════════════════════
   ProbabilitySim — probability & law of large numbers
   coin flip · single die · two-dice sum
   histogram + convergence chart + animated visuals
   ══════════════════════════════════════════════════════════════ */

class ProbabilitySim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;

    /* parameters */
    this.mode   = 'coin';   // 'coin' | 'dice' | 'dice2'
    this.trials = 100;      // target total
    this.speed  = 5;        // trials per frame

    /* state */
    this.results     = [];  // outcome per trial
    this.distribution = {}; // outcome <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> count
    this._convHist   = [];  // running freq for convergence chart
    this._trackKey   = null; // key tracked for convergence

    /* animation */
    this.playing  = false;
    this._raf     = null;
    this._animT   = 0;      // animation phase for coin/dice visual
    this._lastOutcome = null;
    this._shakeT  = 0;

    this.onUpdate = null;

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

  /* ── presets ──────────────────────────────────── */

  static PRESETS = {
    coin_100:  { mode: 'coin',  trials: 100,  speed: 2 },
    coin_1000: { mode: 'coin',  trials: 1000, speed: 10 },
    dice_100:  { mode: 'dice',  trials: 100,  speed: 2 },
    dice2_500: { mode: 'dice2', trials: 500,  speed: 5 },
  };

  preset(name) {
    const p = ProbabilitySim.PRESETS[name];
    if (p) { this.setParams(p); this.reset(); }
  }

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

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
  setParams({ mode, trials, speed } = {}) {
    if (mode   !== undefined) this.mode   = mode;
    if (trials !== undefined) this.trials = Math.max(1, +trials);
    if (speed  !== undefined) this.speed  = Math.max(1, Math.min(50, +speed));
    this._setupMode();
    this.draw();
    this._emit();
  }

  reset() {
    this.pause();
    this.results      = [];
    this.distribution = {};
    this._convHist    = [];
    this._animT       = 0;
    this._lastOutcome = null;
    this._shakeT      = 0;
    this._setupMode();
    this.draw();
    this._emit();
  }

  play() {
    if (this.playing) return;
    this.playing = true;
    this._tick();
  }

  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }

  start() { this.play(); }
  stop()  { this.pause(); }

  info() {
    const n = this.results.length;
    const dist = { ...this.distribution };
    const theo = this._theoretical();
    let chiSq = 0, maxDev = 0;
    for (const k of Object.keys(theo)) {
      const obs = (dist[k] || 0) / (n || 1);
      const exp = theo[k];
      const dev = Math.abs(obs - exp);
      if (dev > maxDev) maxDev = dev;
      if (n > 0) chiSq += ((dist[k] || 0) - n * exp) ** 2 / (n * exp || 1);
    }
    return {
      mode: this.mode,
      totalTrials: n,
      distribution: dist,
      chiSquare: +chiSq.toFixed(4),
      maxDeviation: +maxDev.toFixed(6),
    };
  }

  /* ── internals ───────────────────────────────── */

  _emit() { if (this.onUpdate) this.onUpdate(this.info()); }

  _setupMode() {
    const keys = this._outcomeKeys();
    for (const k of keys) {
      if (!(k in this.distribution)) this.distribution[k] = 0;
    }
    this._trackKey = keys[0]; // convergence tracks first outcome
  }

  _outcomeKeys() {
    if (this.mode === 'coin') return ['О', 'Р'];
    if (this.mode === 'dice') return ['1','2','3','4','5','6'];
    // dice2: sums 2..12
    const keys = [];
    for (let i = 2; i <= 12; i++) keys.push(String(i));
    return keys;
  }

  _theoretical() {
    const t = {};
    if (this.mode === 'coin') {
      t['О'] = 0.5; t['Р'] = 0.5;
    } else if (this.mode === 'dice') {
      for (let i = 1; i <= 6; i++) t[String(i)] = 1 / 6;
    } else {
      // dice2: two dice sum probabilities
      const ways = [0,0,1,2,3,4,5,6,5,4,3,2,1]; // index 0-12, sum 2-12
      for (let s = 2; s <= 12; s++) t[String(s)] = ways[s] / 36;
    }
    return t;
  }

  _rollOnce() {
    if (this.mode === 'coin') return Math.random() < 0.5 ? 'О' : 'Р';
    if (this.mode === 'dice') return String(Math.floor(Math.random() * 6) + 1);
    const d1 = Math.floor(Math.random() * 6) + 1;
    const d2 = Math.floor(Math.random() * 6) + 1;
    return String(d1 + d2);
  }

  _addTrial() {
    if (this.results.length >= this.trials) return false;
    const outcome = this._rollOnce();
    this.results.push(outcome);
    this.distribution[outcome] = (this.distribution[outcome] || 0) + 1;
    this._lastOutcome = outcome;

    // convergence: running frequency of tracked key
    const n = this.results.length;
    const freq = (this.distribution[this._trackKey] || 0) / n;
    this._convHist.push(freq);
    if (this._convHist.length > 500) this._convHist.shift();
    return true;
  }

  _tick() {
    if (!this.playing) return;
    this._raf = requestAnimationFrame(now => {
      const dt = (now - (this._lastTickTs || now)) / 1000;
      this._lastTickTs = now;
      if (window.LabFX) LabFX.particles.update(dt);

      let added = 0;
      for (let i = 0; i < this.speed; i++) {
        if (!this._addTrial()) break;
        added++;
      }
      this._animT += 0.15;
      if (added > 0) {
        this._shakeT = 1;
        if (window.LabFX && now - (this._lastBounceSoundTs || 0) > 120) {
          this._lastBounceSoundTs = now;
          LabFX.sound.play('bounce', { pitch: 1.0 + Math.random() * 0.3 });
        }
      } else this._shakeT *= 0.9;

      this.draw();
      this._emit();

      if (this.results.length >= this.trials) {
        this.pause();
        if (window.LabFX) {
          LabFX.sound.play('chime');
          const ctx = this.ctx;
          const W = this.W, H = this.H;
          LabFX.particles.emit({ ctx, x: W / 2, y: H * 0.4, count: 40, color: '#9B5DE5', shape: 'spark', spread: Math.PI * 2, life: 1400, speed: 120, gravity: 200, glow: true });
        }
        return;
      }
      this._tick();
    });
  }

  /* ── drawing ─────────────────────────────────── */

  draw() {
    const { ctx, W, H } = this;
    if (!W || !H) return;

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

    const vizH  = H * 0.28;
    const histH = H * 0.48;
    const convH = H * 0.24;

    this._drawVisual(ctx, 0, 0, W, vizH);
    this._drawHistogram(ctx, 0, vizH, W, histH);
    this._drawConvergence(ctx, 0, vizH + histH, W, convH);
    this._drawStats(ctx, W, H);
    if (window.LabFX) LabFX.particles.draw(ctx);
  }

  /* ── top visual: coin or dice ──────────────── */

  _drawVisual(ctx, x0, y0, w, h) {
    const cx = x0 + w / 2, cy = y0 + h / 2;

    if (this.mode === 'coin') {
      this._drawCoin(ctx, cx, cy, Math.min(w, h) * 0.32);
    } else if (this.mode === 'dice') {
      this._drawDie(ctx, cx, cy, Math.min(w, h) * 0.34, this._lastOutcome ? +this._lastOutcome : 1);
    } else {
      // dice2: two dice side by side
      const sz = Math.min(w, h) * 0.28;
      const gap = sz * 0.3;
      const last = this._lastOutcome ? +this._lastOutcome : 7;
      const d1 = Math.min(6, Math.max(1, Math.ceil(last / 2)));
      const d2 = last - d1;
      this._drawDie(ctx, cx - sz / 2 - gap, cy, sz, Math.max(1, Math.min(6, d1)));
      this._drawDie(ctx, cx + sz / 2 + gap, cy, sz, Math.max(1, Math.min(6, d2)));
    }

    // trial counter
    ctx.fillStyle = 'rgba(255,255,255,0.45)';
    ctx.font = "bold 13px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
    ctx.fillText(`Испытание ${this.results.length} / ${this.trials}`, x0 + w / 2, y0 + h - 6);
  }

  _drawCoin(ctx, cx, cy, r) {
    const phase = this._animT % (Math.PI * 2);
    const squeeze = Math.abs(Math.cos(phase));
    const showHeads = Math.cos(phase) >= 0;

    ctx.save();
    ctx.translate(cx, cy);
    ctx.scale(Math.max(0.05, squeeze), 1);

    // shadow
    ctx.fillStyle = 'rgba(155,93,229,0.15)';
    ctx.beginPath(); ctx.ellipse(0, r * 0.15, r * 1.1, r * 0.18, 0, 0, Math.PI * 2); ctx.fill();

    // coin body
    const grad = ctx.createRadialGradient(-r * 0.2, -r * 0.2, 0, 0, 0, r);
    if (showHeads) {
      grad.addColorStop(0, '#FFD166');
      grad.addColorStop(1, '#D4950A');
    } else {
      grad.addColorStop(0, '#9B5DE5');
      grad.addColorStop(1, '#6B2FA0');
    }
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();

    // rim
    ctx.strokeStyle = 'rgba(255,255,255,0.3)';
    ctx.lineWidth = 2;
    ctx.stroke();

    // label
    ctx.fillStyle = showHeads ? '#5A3000' : '#E0D0FF';
    ctx.font = `bold ${Math.round(r * 0.6)}px 'Manrope', system-ui, sans-serif`;
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(showHeads ? 'О' : 'Р', 0, 2);

    ctx.restore();
  }

  _drawDie(ctx, cx, cy, size, value) {
    const half = size / 2;
    const shake = this._shakeT * 2;
    const sx = shake * (Math.random() - 0.5);
    const sy = shake * (Math.random() - 0.5);

    ctx.save();
    ctx.translate(cx + sx, cy + sy);

    // shadow
    ctx.fillStyle = 'rgba(6,214,224,0.08)';
    ctx.beginPath(); ctx.roundRect(-half + 4, -half + 6, size, size, size * 0.18); ctx.fill();

    // body
    const grad = ctx.createLinearGradient(-half, -half, half, half);
    grad.addColorStop(0, '#1E1E3A');
    grad.addColorStop(1, '#12122A');
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.roundRect(-half, -half, size, size, size * 0.18); ctx.fill();

    // border
    ctx.strokeStyle = 'rgba(155,93,229,0.4)';
    ctx.lineWidth = 1.5;
    ctx.stroke();

    // dots
    const dotR = size * 0.08;
    const off  = size * 0.26;
    const dots = {
      1: [[0, 0]],
      2: [[-off, -off], [off, off]],
      3: [[-off, -off], [0, 0], [off, off]],
      4: [[-off, -off], [off, -off], [-off, off], [off, off]],
      5: [[-off, -off], [off, -off], [0, 0], [-off, off], [off, off]],
      6: [[-off, -off], [off, -off], [-off, 0], [off, 0], [-off, off], [off, off]],
    };

    const positions = dots[Math.max(1, Math.min(6, value))] || dots[1];
    for (const [dx, dy] of positions) {
      const dg = ctx.createRadialGradient(dx, dy, 0, dx, dy, dotR);
      dg.addColorStop(0, '#FFFFFF');
      dg.addColorStop(1, '#C0C0E0');
      ctx.fillStyle = dg;
      ctx.beginPath(); ctx.arc(dx, dy, dotR, 0, Math.PI * 2); ctx.fill();
    }

    ctx.restore();
  }

  /* ── histogram ─────────────────────────────── */

  _drawHistogram(ctx, x0, y0, w, h) {
    const keys = this._outcomeKeys();
    const theo = this._theoretical();
    const n    = this.results.length || 1;
    const pad  = { l: 48, r: 16, t: 20, b: 34 };
    const pw   = w - pad.l - pad.r;
    const ph   = h - pad.t - pad.b;
    const px   = x0 + pad.l, py = y0 + pad.t;

    // panel bg
    ctx.fillStyle = 'rgba(5,5,20,0.5)';
    ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 4, w - 16, h - 8, 8); ctx.fill();

    // y-axis: relative frequency
    let maxFreq = 0;
    for (const k of keys) {
      const f = (this.distribution[k] || 0) / n;
      if (f > maxFreq) maxFreq = f;
    }
    for (const k of keys) {
      const t = theo[k];
      if (t > maxFreq) maxFreq = t;
    }
    maxFreq = Math.max(maxFreq * 1.15, 0.05);

    // grid lines
    ctx.strokeStyle = 'rgba(255,255,255,0.05)';
    ctx.lineWidth = 0.5;
    for (let i = 0; i <= 4; i++) {
      const gy = py + ph * (1 - i / 4);
      ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
    }

    // y labels
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.font = "9px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    for (let i = 0; i <= 4; i++) {
      const v = (maxFreq * i / 4 * 100).toFixed(0) + '%';
      ctx.fillText(v, px - 6, py + ph * (1 - i / 4));
    }

    // bars
    const barCount = keys.length;
    const gap   = Math.max(2, pw * 0.03);
    const barW  = (pw - gap * (barCount + 1)) / barCount;
    const colors = ['#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166',
                    '#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166','#EF476F'];

    for (let i = 0; i < barCount; i++) {
      const k = keys[i];
      const freq = (this.distribution[k] || 0) / n;
      const bh = (freq / maxFreq) * ph;
      const bx = px + gap + i * (barW + gap);
      const by = py + ph - bh;

      // bar gradient
      const bg = ctx.createLinearGradient(bx, by, bx, py + ph);
      bg.addColorStop(0, colors[i % colors.length]);
      bg.addColorStop(1, colors[i % colors.length] + '66');
      ctx.fillStyle = bg;
      ctx.beginPath(); ctx.roundRect(bx, by, barW, bh, [4, 4, 0, 0]); ctx.fill();

      // glow at top
      if (bh > 4) {
        ctx.fillStyle = colors[i % colors.length] + '33';
        ctx.beginPath(); ctx.roundRect(bx - 2, by - 2, barW + 4, 6, 3); ctx.fill();
      }

      // count + percentage label above bar
      const count = this.distribution[k] || 0;
      const pct   = (freq * 100).toFixed(1);
      ctx.fillStyle = 'rgba(255,255,255,0.7)';
      ctx.font = "bold 9px 'Manrope', system-ui, sans-serif";
      ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
      if (bh > 16) {
        ctx.fillText(count, bx + barW / 2, by - 2);
      } else {
        ctx.fillText(count, bx + barW / 2, py + ph - bh - 2);
      }
      // percentage inside bar
      if (bh > 28) {
        ctx.fillStyle = 'rgba(0,0,0,0.55)';
        ctx.font = "8px 'Manrope', system-ui, sans-serif";
        ctx.textBaseline = 'top';
        ctx.fillText(pct + '%', bx + barW / 2, by + 4);
      }

      // x label
      ctx.fillStyle = 'rgba(255,255,255,0.55)';
      ctx.font = "10px 'Manrope', system-ui, sans-serif";
      ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText(k, bx + barW / 2, py + ph + 6);
    }

    // theoretical probability dashed lines
    ctx.setLineDash([5, 4]);
    ctx.lineWidth = 1.5;
    for (let i = 0; i < barCount; i++) {
      const k = keys[i];
      const tp = theo[k];
      const ly = py + ph - (tp / maxFreq) * ph;
      const bx = px + gap + i * (barW + gap);
      ctx.strokeStyle = colors[i % colors.length] + '88';
      ctx.beginPath();
      ctx.moveTo(bx - 2, ly);
      ctx.lineTo(bx + barW + 2, ly);
      ctx.stroke();
    }
    ctx.setLineDash([]);

    // legend
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.font = "9px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
    ctx.setLineDash([5, 4]);
    ctx.strokeStyle = 'rgba(255,255,255,0.4)';
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(px, y0 + h - 8); ctx.lineTo(px + 18, y0 + h - 8); ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillText('— теор. вероятность', px + 22, y0 + h - 4);
  }

  /* ── convergence chart ─────────────────────── */

  _drawConvergence(ctx, x0, y0, w, h) {
    const pad = { l: 48, r: 16, t: 14, b: 20 };
    const pw  = w - pad.l - pad.r;
    const ph  = h - pad.t - pad.b;
    const px  = x0 + pad.l, py = y0 + pad.t;

    // bg
    ctx.fillStyle = 'rgba(5,5,20,0.5)';
    ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 2, w - 16, h - 4, 8); ctx.fill();

    // title
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.font = "9px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    const trackLabel = this._trackKey;
    ctx.fillText(`Сходимость частоты «${trackLabel}»`, px, y0 + 3);

    // theoretical value
    const theo = this._theoretical();
    const tp   = theo[this._trackKey] || 0;

    // y range
    const yMin = Math.max(0, tp - 0.35);
    const yMax = Math.min(1, tp + 0.35);
    const yRange = yMax - yMin || 0.01;

    // grid
    ctx.strokeStyle = 'rgba(255,255,255,0.04)';
    ctx.lineWidth = 0.5;
    for (let i = 0; i <= 3; i++) {
      const gy = py + ph * (i / 3);
      ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
    }

    // y labels
    ctx.fillStyle = 'rgba(255,255,255,0.25)';
    ctx.font = "8px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    for (let i = 0; i <= 3; i++) {
      const v = yMax - (i / 3) * yRange;
      ctx.fillText(v.toFixed(2), px - 5, py + ph * (i / 3));
    }

    // theoretical dashed line
    const theoY = py + ph * (1 - (tp - yMin) / yRange);
    ctx.setLineDash([6, 4]);
    ctx.strokeStyle = '#FFD166';
    ctx.lineWidth = 1.2;
    ctx.beginPath(); ctx.moveTo(px, theoY); ctx.lineTo(px + pw, theoY); ctx.stroke();
    ctx.setLineDash([]);

    // label for theoretical
    ctx.fillStyle = '#FFD166';
    ctx.font = "8px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
    ctx.fillText('p=' + tp.toFixed(4), px + pw, theoY - 3);

    // convergence line
    const data = this._convHist;
    if (data.length < 2) return;

    ctx.beginPath();
    ctx.strokeStyle = '#06D6E0';
    ctx.lineWidth = 1.5;
    for (let i = 0; i < data.length; i++) {
      const lx = px + (i / (data.length - 1)) * pw;
      const ly = py + ph * (1 - (data[i] - yMin) / yRange);
      const cly = Math.max(py, Math.min(py + ph, ly));
      i === 0 ? ctx.moveTo(lx, cly) : ctx.lineTo(lx, cly);
    }
    ctx.stroke();

    // x label
    ctx.fillStyle = 'rgba(255,255,255,0.25)';
    ctx.font = "8px 'Manrope', system-ui, sans-serif";
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    ctx.fillText('номер испытания →', px + pw / 2, y0 + h - 14);
  }

  /* ── stats overlay ─────────────────────────── */

  _drawStats(ctx, W) {
    const info = this.info();
    const px = 12, py = 10, pw = 170, ph = 72;

    ctx.fillStyle = 'rgba(5,5,20,0.82)';
    ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();

    ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.font = "10px 'Manrope', system-ui, sans-serif";
    const lh = 15;

    const modeLabel = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' }[this.mode] || this.mode;
    ctx.fillStyle = '#9B5DE5';
    ctx.fillText(`Режим: ${modeLabel}`, px + 10, py + 8);

    ctx.fillStyle = '#06D6E0';
    ctx.fillText(`N = ${info.totalTrials}`, px + 10, py + 8 + lh);

    ctx.fillStyle = '#7BF5A4';
    ctx.fillText(`χ² = ${info.chiSquare}`, px + 10, py + 8 + lh * 2);

    ctx.fillStyle = '#FFD166';
    ctx.fillText(`max Δ = ${info.maxDeviation.toFixed(4)}`, px + 10, py + 8 + lh * 3);
  }
}

if (typeof module !== 'undefined') module.exports = ProbabilitySim;

/* ─── lab UI init ─────────────────────────────────── */
  function _openProbability() {
    document.getElementById('sim-topbar-title').textContent = 'Теория вероятностей';
    _simShow('sim-probability');
    _registerSimState('probability', () => probSim?.getParams(), st => probSim?.setParams(st));
    if (_embedMode) _startStateEmit('probability');
    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!probSim) {
        probSim = new ProbabilitySim(document.getElementById('probability-canvas'));
        probSim.onUpdate = _probUpdateUI;
      }
      probSim.fit();
      probSim.reset();
      probSim.play();
    }));
  }

  function probMode(mode, btn) {
    document.querySelectorAll('.prob-mode-btn').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');
    if (probSim) { probSim.setParams({ mode }); probSim.reset(); probSim.play(); }
    if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
  }

  function probPreset(mode, trials) {
    document.querySelectorAll('.prob-mode-btn').forEach(b => {
      b.classList.toggle('active', b.textContent.toLowerCase().includes(mode === 'coin' ? 'монет' : mode === 'dice2' ? '2 куб' : 'кубик'));
    });
    if (probSim) { probSim.setParams({ mode, trials }); probSim.reset(); probSim.play(); }
  }

  function _probUpdateUI(info) {
    const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
    v('probbar-v1', info.totalTrials);
    v('probbar-v2', typeof info.maxDeviation === 'number' ? (info.maxDeviation * 100).toFixed(1) + '%' : '—');
    v('probbar-v3', typeof info.chiSquare === 'number' ? info.chiSquare.toFixed(2) : '—');
    const modeNames = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' };
    v('probbar-v4', modeNames[info.mode] || info.mode);
  }

  /* ── bohr atom ── */

